feat: complete legacy parity and modernize operator UI

This commit is contained in:
2026-07-22 12:34:46 +09:00
parent fc4007d676
commit 939c252d23
149 changed files with 26515 additions and 1736 deletions

View File

@@ -37,6 +37,10 @@
</PropertyGroup>
<ItemGroup>
<!-- Closed runtime contract: every active alias and non-external scene asset must exist. -->
<LegacyRequiredScene Include="5001;N5001;5006;5011;5016;50160;5023;5024;5025;5026;5029;8018;8032;5032;5037;5074;5076;5077;5078;5079;5080;5081;5082;5083;5084;5085;5086;50860;5087;5088;6001;6067;8001;8002;8003;8035;8061;8040;8046;8051;8056;8067;5068;5070;5072" />
<LegacyRequiredBuiltInAsset Include="images\주유기merge.png;images\35752913_l.jpg;Images\그림_빨강.png;Images\그림_검정.png;Images\그림_파랑.png;Images\프리마켓.png;Images\애프터마켓.png;Images\KRX.png;Images\NXT.png" />
<!-- Explicit allowlist: these legacy Res assets contain UI/menu data only. -->
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\a.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\aa.png" />
@@ -73,6 +77,16 @@
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\해외.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\환율.ini" />
<!--
Same-origin GraphE previews are a second, closed projection of four public
bitmap assets. Never map the complete Res directory into WebView2: ordinary
local Debug output can also contain credential-bearing MmoneyCoder.ini.
-->
<LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\pie.bmp" />
<LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\Grow.bmp" />
<LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\sell.bmp" />
<LegacyFinancialPreviewAsset Include="$(LegacyResSourceRoot)\profit.bmp" />
<Compile Include="..\..\LegacySceneRuntimeFactory.cs"
Link="Runtime\LegacySceneRuntimeFactory.cs" />
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
@@ -106,6 +120,12 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="@(LegacyFinancialPreviewAsset)">
<Link>Web\Previews\%(Filename)%(Extension)</Link>
<TargetPath>Web\Previews\%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<!--
The active legacy DB INI contains credentials. It is available beside the
executable for local F5/unpackaged development only and is never MSIX Content.
@@ -121,8 +141,15 @@
Text="Legacy Cuts source directory was not found: $(LegacyCutsSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)')"
Text="Legacy Res source directory was not found: $(LegacyResSourceRoot)." />
<Error Condition="!Exists('$(LegacyCutsSourceRoot)\5001.t2s')"
Text="The required legacy scene file Cuts\5001.t2s is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\pie.bmp') or
!Exists('$(LegacyResSourceRoot)\Grow.bmp') or
!Exists('$(LegacyResSourceRoot)\sell.bmp') or
!Exists('$(LegacyResSourceRoot)\profit.bmp')"
Text="The four required GraphE preview bitmaps are missing from $(LegacyResSourceRoot)." />
<Error Condition="!Exists('$(LegacyCutsSourceRoot)\%(LegacyRequiredScene.Identity).t2s')"
Text="A required active legacy scene file is missing: Cuts\%(LegacyRequiredScene.Identity).t2s." />
<Error Condition="!Exists('$(LegacyCutsSourceRoot)\%(LegacyRequiredBuiltInAsset.Identity)')"
Text="A required built-in legacy scene asset is missing: Cuts\%(LegacyRequiredBuiltInAsset.Identity)." />
<Error Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and
'$(PublishAppxPackage)' != 'true' and
!Exists('$(LegacyResSourceRoot)\MmoneyCoder.ini')"
@@ -180,11 +207,12 @@
BeforeTargets="_ComputeAppxPackagePayload">
<ItemGroup>
<_ForbiddenLegacyDatabasePackagePayload Include="@(PackagingOutputs)"
Condition="'%(PackagingOutputs.TargetPath)' == 'Res\MmoneyCoder.ini' or
Condition="'%(PackagingOutputs.Filename)%(PackagingOutputs.Extension)' == 'MmoneyCoder.ini' or
'%(PackagingOutputs.TargetPath)' == 'Res\MmoneyCoder.ini' or
'%(PackagingOutputs.TargetPath)' == 'Res/MmoneyCoder.ini'" />
</ItemGroup>
<Error Condition="'@(_ForbiddenLegacyDatabasePackagePayload)' != ''"
Text="Credential-bearing Res\MmoneyCoder.ini must never enter an MSIX payload or package recipe." />
Text="Credential-bearing MmoneyCoder.ini must never enter an MSIX payload or package recipe at any target path." />
</Target>
<ItemGroup>

View File

@@ -0,0 +1,244 @@
using System.Runtime.InteropServices;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow
{
private Task<string?> PickBackgroundFileAsync(string trustedBackgroundDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(trustedBackgroundDirectory);
var initialDirectory = Path.GetFullPath(trustedBackgroundDirectory);
var completion = new TaskCompletionSource<string?>(
TaskCreationOptions.RunContinuationsAsynchronously);
void ShowPicker()
{
try
{
completion.TrySetResult(
LegacyBackgroundFileDialog.Show(
WindowNative.GetWindowHandle(this),
initialDirectory));
}
catch (Exception exception)
{
completion.TrySetException(exception);
}
}
if (DispatcherQueue.HasThreadAccess)
{
ShowPicker();
}
else if (!DispatcherQueue.TryEnqueue(ShowPicker))
{
completion.TrySetException(
new InvalidOperationException("The background picker could not reach the UI thread."));
}
return completion.Task;
}
private static class LegacyBackgroundFileDialog
{
private const int CancelledHResult = unchecked((int)0x800704C7);
private const uint FileSystemPathDisplayName = 0x80058000;
private static readonly Guid FileOpenDialogClassId =
new("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7");
public static string? Show(nint owner, string initialDirectory)
{
if (owner == nint.Zero || !Directory.Exists(initialDirectory))
{
throw new DirectoryNotFoundException(
"The trusted background directory is unavailable.");
}
IFileDialog? dialog = null;
IShellItem? initialFolder = null;
IShellItem? result = null;
try
{
var dialogType = Type.GetTypeFromCLSID(
FileOpenDialogClassId,
throwOnError: true) ??
throw new InvalidOperationException(
"The Windows file-open dialog is unavailable.");
dialog = (IFileDialog)(Activator.CreateInstance(dialogType) ??
throw new InvalidOperationException(
"The Windows file-open dialog could not be created."));
dialog.GetOptions(out var existingOptions);
dialog.SetOptions(
existingOptions |
FileOpenOptions.NoChangeDirectory |
FileOpenOptions.ForceFileSystem |
FileOpenOptions.PathMustExist |
FileOpenOptions.FileMustExist |
FileOpenOptions.NoDereferenceLinks |
FileOpenOptions.DoNotAddToRecent);
dialog.SetTitle("다음 장면 배경 선택");
dialog.SetOkButtonLabel("선택");
dialog.SetDefaultExtension("vrv");
dialog.SetFileTypes(
3,
new[]
{
new DialogFilter("VRV/JPG/PNG 배경", "*.vrv;*.jpg;*.jpeg;*.png"),
new DialogFilter("VRV 영상", "*.vrv"),
new DialogFilter("JPG/PNG 이미지", "*.jpg;*.jpeg;*.png")
});
dialog.SetFileTypeIndex(1);
var shellItemId = typeof(IShellItem).GUID;
var createResult = SHCreateItemFromParsingName(
initialDirectory,
nint.Zero,
ref shellItemId,
out initialFolder);
Marshal.ThrowExceptionForHR(createResult);
dialog.SetFolder(initialFolder);
dialog.SetDefaultFolder(initialFolder);
var showResult = dialog.Show(owner);
if (showResult == CancelledHResult)
{
return null;
}
Marshal.ThrowExceptionForHR(showResult);
dialog.GetResult(out result);
result.GetDisplayName(FileSystemPathDisplayName, out var pathPointer);
try
{
return Marshal.PtrToStringUni(pathPointer) ??
throw new IOException("The selected background path is unavailable.");
}
finally
{
Marshal.FreeCoTaskMem(pathPointer);
}
}
finally
{
ReleaseComObject(result);
ReleaseComObject(initialFolder);
ReleaseComObject(dialog);
}
}
private static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
_ = Marshal.FinalReleaseComObject(value);
}
}
[Flags]
private enum FileOpenOptions : uint
{
NoChangeDirectory = 0x00000008,
ForceFileSystem = 0x00000040,
PathMustExist = 0x00000800,
FileMustExist = 0x00001000,
NoDereferenceLinks = 0x00100000,
DoNotAddToRecent = 0x02000000
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private readonly struct DialogFilter(string name, string specification)
{
[MarshalAs(UnmanagedType.LPWStr)]
public readonly string Name = name;
[MarshalAs(UnmanagedType.LPWStr)]
public readonly string Specification = specification;
}
[ComImport]
[Guid("42F85136-DB7E-439C-85F1-E4075D135FC8")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IFileDialog
{
[PreserveSig]
int Show(nint owner);
void SetFileTypes(
uint count,
[MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)]
DialogFilter[] filters);
void SetFileTypeIndex(uint index);
void GetFileTypeIndex(out uint index);
void Advise(nint events, out uint cookie);
void Unadvise(uint cookie);
void SetOptions(FileOpenOptions options);
void GetOptions(out FileOpenOptions options);
void SetDefaultFolder(IShellItem folder);
void SetFolder(IShellItem folder);
void GetFolder(out IShellItem folder);
void GetCurrentSelection(out IShellItem item);
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name);
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name);
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string text);
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string label);
void GetResult(out IShellItem item);
void AddPlace(IShellItem item, uint placement);
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string extension);
void Close(int result);
void SetClientGuid(ref Guid guid);
void ClearClientData();
void SetFilter(nint filter);
}
[ComImport]
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellItem
{
void BindToHandler(
nint bindContext,
ref Guid handlerId,
ref Guid interfaceId,
out nint result);
void GetParent(out IShellItem parent);
void GetDisplayName(uint displayName, out nint name);
void GetAttributes(uint mask, out uint attributes);
void Compare(IShellItem item, uint hint, out int order);
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
private static extern int SHCreateItemFromParsingName(
[MarshalAs(UnmanagedType.LPWStr)] string path,
nint bindContext,
ref Guid interfaceId,
[MarshalAs(UnmanagedType.Interface)] out IShellItem shellItem);
}
}

View File

@@ -7,8 +7,6 @@ using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Safety;
using Microsoft.UI.Xaml;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
@@ -19,6 +17,7 @@ public sealed partial class MainWindow
private LegacyPlayoutWorkflow? _playoutWorkflow;
private PlayoutOptions? _playoutOptions;
private MutableLegacySceneCueCompositionOptionsSource? _compositionOptions;
private MutableLegacySceneCueCompositionOptionsSource? _stagedCompositionOptions;
private string? _playoutInitializationError;
private string? _playoutInitializationWarning;
private Task? _refreshTask;
@@ -48,6 +47,8 @@ public sealed partial class MainWindow
: null;
_compositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
initialComposition);
_stagedCompositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
initialComposition);
_playoutEngine = PlayoutEngineFactory.Create(
_playoutOptions,
_playoutLaunchAuthorization);
@@ -175,6 +176,12 @@ public sealed partial class MainWindow
}
StopRefreshLoop();
if (command is LegacyOperatorPlayoutCommand.Prepare or
LegacyOperatorPlayoutCommand.TakeIn or
LegacyOperatorPlayoutCommand.Next)
{
ApplyStagedCompositionForNextCue();
}
PlayoutResult result;
switch (command)
{
@@ -202,7 +209,11 @@ public sealed partial class MainWindow
cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.Next:
result = await workflow.NextAsync(cancellationToken).ConfigureAwait(false);
var livePlaylist = _controller.CreatePlayoutRequest(
_compositionOptions!.Current.FadeDuration).Playlist;
result = await workflow.NextAsync(
livePlaylist,
cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.TakeOut:
result = await workflow.TakeOutAsync(
@@ -463,7 +474,9 @@ public sealed partial class MainWindow
}
var source = _compositionOptions!;
source.Update(source.Current with { FadeDuration = fadeDuration });
var updated = source.Current with { FadeDuration = fadeDuration };
source.Update(updated);
_stagedCompositionOptions!.Update(updated);
return _controller.ReportInformation(
$"DissolveTime {fadeDuration + 1}을 다음 PREPARE부터 적용합니다.");
}
@@ -497,12 +510,13 @@ public sealed partial class MainWindow
try
{
if (!CanChangeComposition())
if (!CanStageBackground())
{
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
return _controller.ReportError(
"송출 결과가 확정되지 않은 동안에는 다음 장면 배경을 변경할 수 없습니다.");
}
var source = _compositionOptions!;
var source = _stagedCompositionOptions!;
if (source.Current.BackgroundKind == LegacySceneBackgroundKind.None)
{
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
@@ -544,16 +558,17 @@ public sealed partial class MainWindow
try
{
if (!CanChangeComposition())
if (!CanStageBackground())
{
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
return _controller.ReportError(
"송출 결과가 확정되지 않은 동안에는 다음 장면 배경을 변경할 수 없습니다.");
}
// MainForm.btnback_Click assigns 배경\기본.vrv before opening the
// picker. Preserve that observable behavior when the trusted default is
// present, while still allowing the operator to choose another asset if it
// is not.
var source = _compositionOptions!;
var source = _stagedCompositionOptions!;
try
{
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
@@ -565,24 +580,22 @@ public sealed partial class MainWindow
{
}
var picker = new FileOpenPicker
if (!PlayoutSceneCompositionFactory.TryGetTrustedBackgroundDirectory(
_playoutOptions!,
out var trustedBackgroundDirectory))
{
SuggestedStartLocation = PickerLocationId.PicturesLibrary,
ViewMode = PickerViewMode.List
};
foreach (var extension in new[] { ".vrv", ".jpg", ".jpeg", ".png" })
{
picker.FileTypeFilter.Add(extension);
return _controller.ReportError(
"설정에서 사용할 배경 폴더를 먼저 지정해 주세요.");
}
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this));
var selected = await picker.PickSingleFileAsync();
if (selected is null)
var selectedPath = await PickBackgroundFileAsync(
trustedBackgroundDirectory).ConfigureAwait(false);
if (selectedPath is null)
{
return _controller.Current;
}
if (!CanChangeComposition())
if (!CanStageBackground())
{
return _controller.ReportError(
"파일 선택 중 송출 상태가 바뀌어 배경 변경을 취소했습니다.");
@@ -591,7 +604,7 @@ public sealed partial class MainWindow
source.Update(PlayoutSceneCompositionFactory.CreateOperatorSelection(
_playoutOptions!,
source.Current.FadeDuration,
selected.Path));
selectedPath));
_playoutInitializationWarning = null;
return _controller.ReportInformation(
"선택한 배경을 다음 PREPARE부터 적용합니다.");
@@ -615,6 +628,7 @@ public sealed partial class MainWindow
var workflow = _playoutWorkflow?.State;
var refresh = _refreshEpoch.ReadState();
return _playoutOptions is not null && _compositionOptions is not null &&
_stagedCompositionOptions is not null &&
status is not null && workflow is not null &&
Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
@@ -628,7 +642,33 @@ public sealed partial class MainWindow
(_refreshTask is null || _refreshTask.IsCompleted);
}
private bool CanAcceptOperatorMutation()
private bool CanStageBackground()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
return _playoutOptions is not null && _compositionOptions is not null &&
_stagedCompositionOptions is not null && status is not null &&
workflow is not null &&
Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
Volatile.Read(ref _playoutShutdownStarted) == 0 &&
status.State != PlayoutConnectionState.OutcomeUnknown &&
!status.IsPlayCompletionPending &&
!status.IsTakeOutCompletionPending;
}
private void ApplyStagedCompositionForNextCue()
{
var active = _compositionOptions ??
throw new InvalidOperationException("The active composition source is unavailable.");
var staged = _stagedCompositionOptions ??
throw new InvalidOperationException("The staged composition source is unavailable.");
active.Update(staged.Current);
}
private bool CanAcceptOperatorMutation(
LegacyUiIntent intent,
out string rejectionMessage)
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
@@ -637,21 +677,158 @@ public sealed partial class MainWindow
{
// A disabled or unavailable playout runtime must not prevent the operator
// from composing a playlist. PREPARE will still fail closed later.
rejectionMessage = string.Empty;
return true;
}
return Volatile.Read(ref _playoutBusy) == 0 &&
// A cut double-click only appends a local row at the safe playlist tail.
// Once the refresh has released the shared intent gate, that append is
// independent of the scheduler remaining active between refreshes. All
// uncertain/pending/busy states below remain fail-closed.
var isSafeCutTailAppend = intent is
LegacyCutPointerDownIntent { AllowAppend: true };
// MainForm kept its Space and "all" enabled-state gestures available while
// PROGRAM was active. They only change the native operator playlist; NEXT
// stops the refresh loop before the workflow adopts those flags. Therefore
// they are independent between refresh ticks just like a safe tail append.
var isSafeProgramEnabledStateMutation = intent is
LegacySetAllPlaylistEnabledIntent or
LegacyToggleActivePlaylistEnabledIntent;
var refreshAllowsMutation = isSafeCutTailAppend ||
isSafeProgramEnabledStateMutation ||
(!refresh.IsActive && (_refreshTask is null || _refreshTask.IsCompleted));
var runtimeAcceptsIndependentMutation = Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
Volatile.Read(ref _playoutShutdownStarted) == 0 &&
status.State != PlayoutConnectionState.OutcomeUnknown &&
!status.IsPlayCompletionPending &&
!status.IsTakeOutCompletionPending &&
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
!refresh.IsActive &&
(_refreshTask is null || _refreshTask.IsCompleted);
refreshAllowsMutation;
if (!runtimeAcceptsIndependentMutation)
{
rejectionMessage =
"송출 명령 처리 중이거나 결과를 확인할 수 없는 상태에서는 편성 및 운영 데이터를 변경할 수 없습니다.";
return false;
}
if (RequiresIdlePlaylistMutation(intent) && !IsPlaylistMutationIdle(status, workflow))
{
rejectionMessage = intent is
LegacyLoadSelectedNamedPlaylistIntent or LegacyLoadNamedPlaylistByIdIntent
? "송출 중에는 다른 DB 재생목록으로 교체할 수 없습니다. TAKE OUT 후 다시 시도하세요."
: "송출 중에는 행 이동·마우스 개별 체크·전체 삭제를 할 수 없습니다. Space/전체 활성 체크, 새 컷 추가와 안전한 후속 행 삭제는 계속 사용할 수 있습니다.";
return false;
}
if (isSafeProgramEnabledStateMutation &&
!CanMutatePlaylistEnabledState(status, workflow))
{
rejectionMessage =
"플레이리스트 활성 체크는 대기 또는 PROGRAM 상태에서만 변경할 수 있습니다.";
return false;
}
if (intent is LegacyDeleteSelectedPlaylistRowsIntent &&
!CanDeleteSelectedPlaylistTail(status, workflow))
{
rejectionMessage =
"송출 중에는 현재 송출 행과 그 이전 행을 삭제할 수 없습니다. 현재 행 뒤의 선택 행만 삭제할 수 있습니다.";
return false;
}
rejectionMessage = string.Empty;
return true;
}
private static bool RequiresIdlePlaylistMutation(LegacyUiIntent intent) => intent is
LegacySetPlaylistEnabledIntent or
LegacyMoveSelectedPlaylistRowsIntent or
LegacyReorderPlaylistRowsIntent or
LegacyDeleteAllPlaylistRowsIntent or
LegacyLoadSelectedNamedPlaylistIntent or
LegacyLoadNamedPlaylistByIdIntent;
private static bool IsPlaylistMutationIdle(
PlayoutStatus status,
LegacyPlayoutWorkflowState workflow) =>
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
string.IsNullOrWhiteSpace(workflow.CurrentEntryId);
private bool CanDeleteSelectedPlaylistTail(
PlayoutStatus status,
LegacyPlayoutWorkflowState workflow)
{
if (IsPlaylistMutationIdle(status, workflow))
{
return true;
}
if (string.IsNullOrWhiteSpace(workflow.CurrentEntryId) ||
workflow.CurrentCueIndexZeroBased < 0)
{
return false;
}
var playlist = _controller.Current.Playlist;
var currentMatches = playlist
.Select(static (row, index) => (row, index))
.Where(candidate => string.Equals(
candidate.row.RowId,
workflow.CurrentEntryId,
StringComparison.Ordinal))
.Select(static candidate => candidate.index)
.Take(2)
.ToArray();
if (currentMatches.Length != 1)
{
return false;
}
var currentIndex = currentMatches[0];
if (currentIndex != workflow.CurrentCueIndexZeroBased)
{
return false;
}
return playlist
.Select(static (row, index) => (row, index))
.Where(static candidate => candidate.row.IsSelected)
.All(candidate => candidate.index > currentIndex);
}
private bool CanMutatePlaylistEnabledState(
PlayoutStatus status,
LegacyPlayoutWorkflowState workflow)
{
if (IsPlaylistMutationIdle(status, workflow))
{
return true;
}
// PREPARED is deliberately not opened: unlike PROGRAM, the original parity
// evidence does not establish a safe checkbox contract for that phase.
if (string.IsNullOrWhiteSpace(status.OnAirSceneName) ||
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) ||
string.IsNullOrWhiteSpace(workflow.CurrentEntryId) ||
workflow.CurrentCueIndexZeroBased < 0)
{
return false;
}
var playlist = _controller.Current.Playlist;
var currentIndex = workflow.CurrentCueIndexZeroBased;
return currentIndex < playlist.Count &&
string.Equals(
playlist[currentIndex].RowId,
workflow.CurrentEntryId,
StringComparison.Ordinal) &&
playlist.Count(row => string.Equals(
row.RowId,
workflow.CurrentEntryId,
StringComparison.Ordinal)) == 1;
}
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
@@ -670,6 +847,7 @@ public sealed partial class MainWindow
LegacyActivateIndustrySectionIntent or
LegacyActivateOverseasActionIntent or
LegacyAddComparisonPairIntent or
LegacyImportComparisonPairsIntent or
LegacyMoveComparisonPairIntent or
LegacyDeleteSelectedComparisonPairIntent or
LegacyDeleteAllComparisonPairsIntent or
@@ -686,6 +864,7 @@ public sealed partial class MainWindow
LegacyEditUc6SelectedExpertIntent or
LegacyDeleteUc6SelectedExpertIntent or
LegacyBeginCreateThemeCatalogIntent or
LegacyChangeCreateThemeMarketIntent or
LegacyBeginCreateExpertCatalogIntent or
LegacyAddOperatorCatalogStockIntent or
LegacyActivateOperatorCatalogStockIntent or
@@ -704,6 +883,7 @@ public sealed partial class MainWindow
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent or
LegacySaveManualFinancialIntent or
LegacySaveManualFinancialRawIntent or
LegacyDeleteManualFinancialIntent or
LegacyDeleteAllManualFinancialIntent or
LegacyActivateManualFinancialActionIntent or
@@ -730,6 +910,7 @@ public sealed partial class MainWindow
LegacyDeleteSelectedComparisonPairIntent or
LegacyDeleteAllComparisonPairsIntent or
LegacySaveManualFinancialIntent or
LegacySaveManualFinancialRawIntent or
LegacyDeleteManualFinancialIntent or
LegacyDeleteAllManualFinancialIntent or
LegacySaveManualNetSellIntent or
@@ -767,12 +948,51 @@ public sealed partial class MainWindow
private bool IsOperatorMutationQuarantined() =>
Volatile.Read(ref _operatorMutationQuarantined) != 0;
private LegacyWorkflowNextKind ResolveOperatorNextKind(
LegacyOperatorPlayoutPhase phase,
LegacyPlayoutWorkflowState? workflow)
{
var retainedKind = workflow?.NextKind ?? LegacyWorkflowNextKind.None;
if (phase != LegacyOperatorPlayoutPhase.Program || workflow is null)
{
return retainedKind;
}
// The workflow snapshot is intentionally frozen until NEXT. MainForm's
// PROGRAM Space/"all" gestures, however, immediately changed which future
// row NEXT would choose. Derive the visible/available NEXT state from the
// live operator flags without mutating the scene retained by the engine.
if (!workflow.IsLastPage)
{
return LegacyWorkflowNextKind.PageNext;
}
var currentIndex = workflow.CurrentCueIndexZeroBased;
var playlist = _controller.Current.Playlist;
if (currentIndex < 0 || currentIndex >= playlist.Count ||
string.IsNullOrWhiteSpace(workflow.CurrentEntryId) ||
!string.Equals(
playlist[currentIndex].RowId,
workflow.CurrentEntryId,
StringComparison.Ordinal))
{
return LegacyWorkflowNextKind.None;
}
return playlist
.Skip(currentIndex + 1)
.Any(static row => row.IsEnabled)
? LegacyWorkflowNextKind.PlaylistNext
: LegacyWorkflowNextKind.EndOfPlaylist;
}
private LegacyOperatorPlayoutSnapshot CreatePlayoutSnapshot()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
var refresh = _refreshEpoch.ReadState();
var composition = _compositionOptions?.Current ??
var composition = _stagedCompositionOptions?.Current ??
_compositionOptions?.Current ??
LegacySceneCueCompositionOptions.Default;
var quarantined = Volatile.Read(ref _playoutQuarantined) != 0 ||
status?.State == PlayoutConnectionState.OutcomeUnknown;
@@ -781,6 +1001,7 @@ public sealed partial class MainWindow
: !string.IsNullOrWhiteSpace(status?.PreparedSceneName)
? LegacyOperatorPlayoutPhase.Prepared
: LegacyOperatorPlayoutPhase.Idle;
var nextKind = ResolveOperatorNextKind(phase, workflow);
var backgroundEnabled =
composition.BackgroundKind != LegacySceneBackgroundKind.None;
var snapshot = new LegacyOperatorPlayoutSnapshot(
@@ -806,7 +1027,7 @@ public sealed partial class MainWindow
workflow?.ItemCount ?? 0,
workflow?.CurrentPageItemCount ?? 0,
workflow?.IsLastPage ?? true,
workflow?.NextKind ?? LegacyWorkflowNextKind.None,
nextKind,
Volatile.Read(ref _playoutBusy) != 0,
refresh.IsActive,
refresh.CompletedRefreshCount,
@@ -817,7 +1038,7 @@ public sealed partial class MainWindow
backgroundEnabled
? Path.GetFileName(composition.BackgroundAssetPath) ?? string.Empty
: string.Empty,
CanChangeComposition(),
CanStageBackground(),
quarantined
? "결과 불명확 상태입니다. 명령을 반복하지 마세요."
: refresh.Message.Length > 0

View File

@@ -137,9 +137,11 @@ public sealed partial class MainWindow
},
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
SaveOperatorSettings(
next,
$"{OperatorFolderLabel(kind)} 폴더를 저장했습니다. 앱을 다시 시작하면 적용됩니다.");
var savedMessage = kind == LegacyOperatorFolderKind.Design &&
validated.MissingOptionalExternalAssetCount > 0
? $"디자인 폴더를 저장했습니다. 외부 영상 {validated.MissingOptionalExternalAssetCount}개가 없어 관련 장면만 제한됩니다. 앱을 다시 시작하면 적용됩니다."
: $"{OperatorFolderLabel(kind)} 폴더를 저장했습니다. 앱을 다시 시작하면 적용됩니다.";
SaveOperatorSettings(next, savedMessage);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -251,7 +253,7 @@ public sealed partial class MainWindow
LegacyOperatorSettingsFolderKind.Scene,
_operatorSettings.SceneDirectory,
"앱 기본 디자인 폴더",
"필수 장면 확인됨");
"활성 장면·기본 자산 확인됨");
_resourceFolderSnapshot = CreateOperatorFolderSnapshot(
LegacyOperatorSettingsFolderKind.Resource,
_operatorSettings.ResourceDirectory,
@@ -290,8 +292,9 @@ public sealed partial class MainWindow
!LegacyOperatorFolderValidator.TryValidate(
kind,
defaultPath,
out _,
out _))
out var defaultFolder,
out _) ||
defaultFolder is null)
{
return new LegacyOperatorFolderSnapshot(
defaultDisplay,
@@ -304,7 +307,7 @@ public sealed partial class MainWindow
defaultDisplay,
IsCustom: false,
IsValid: true,
validStatus);
FolderStatus(kind, defaultFolder, validStatus));
}
var validation = LegacyOperatorFolderValidator.Validate(kind, configuredPath);
@@ -320,7 +323,7 @@ public sealed partial class MainWindow
var status = kind == LegacyOperatorSettingsFolderKind.Resource &&
validation.Folder.DatabaseIniDetected
? "UI·DB 설정 검증됨"
: validStatus;
: FolderStatus(kind, validation.Folder, validStatus);
return new LegacyOperatorFolderSnapshot(
validation.Folder.AbbreviatedPath,
IsCustom: true,
@@ -328,6 +331,15 @@ public sealed partial class MainWindow
status);
}
private static string FolderStatus(
LegacyOperatorSettingsFolderKind kind,
LegacyOperatorValidatedFolder folder,
string validStatus) =>
kind == LegacyOperatorSettingsFolderKind.Scene &&
folder.MissingOptionalExternalAssetCount > 0
? $"핵심 자산 확인됨 · 외부 영상 {folder.MissingOptionalExternalAssetCount}개 제외"
: validStatus;
private LegacyOperatorSettingsSnapshot CreateOperatorSettingsSnapshot() => new(
Volatile.Read(ref _operatorSettingsRevision),
_designFolderSnapshot,

View File

@@ -1,4 +1,6 @@
using System.Runtime.InteropServices;
using CorePagePlanProvider =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.ILegacyScenePagePlanProvider;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
@@ -22,6 +24,7 @@ public sealed partial class MainWindow : Window
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly SemaphoreSlim _orderedLocalSelectionIntentGate = new(1, 1);
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
private readonly WindowSubclassProcedure _windowSubclassProcedure;
@@ -86,11 +89,13 @@ public sealed partial class MainWindow : Window
IWorldStockSearchService worldStockSearchService;
IManualFinancialScreenService manualFinancialService;
IStockSearchService manualFinancialStockSearchService;
IOperatorCatalogStockSearchService operatorCatalogStockSearchService;
INamedPlaylistPersistenceService namedPlaylistPersistenceService;
IThemeCatalogPersistenceService themeCatalogPersistenceService;
IExpertCatalogPersistenceService expertCatalogPersistenceService;
IStockMasterIdentityValidationService stockMasterIdentityValidationService;
IOperatorCatalogSchemaValidationService operatorCatalogSchemaValidationService;
CorePagePlanProvider? fixedPagePlanProvider = null;
string? initializationError = null;
try
{
@@ -107,6 +112,8 @@ public sealed partial class MainWindow : Window
legacyOverridePath: selectedLegacyDatabaseIni,
preferLegacyOverride: selectedLegacyDatabaseIni is not null);
DataQueryExecutor.Configure(_databaseRuntime.Executor);
fixedPagePlanProvider = LegacySceneRuntimeFactory.CreatePagePlanProvider(
_databaseRuntime.Executor);
var databaseMutationAuthorization =
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
@@ -131,8 +138,11 @@ public sealed partial class MainWindow : Window
_databaseRuntime.Options.Resilience,
errorDetector: null,
mutationAuthorization: databaseMutationAuthorization));
manualFinancialStockSearchService = new LegacyStockSearchService(
var sharedStockSearchService = new LegacyStockSearchService(
_databaseRuntime.Executor);
manualFinancialStockSearchService = sharedStockSearchService;
operatorCatalogStockSearchService =
new LegacyOperatorCatalogStockSearchService(sharedStockSearchService);
namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService(
_databaseRuntime.Executor,
new OracleNamedPlaylistMutationExecutor(
@@ -179,6 +189,8 @@ public sealed partial class MainWindow : Window
initializationError);
manualFinancialStockSearchService = new UnavailableStockSearchService(
initializationError);
operatorCatalogStockSearchService =
new UnavailableOperatorCatalogStockSearchService(initializationError);
namedPlaylistPersistenceService = new UnavailableNamedPlaylistPersistenceService(
initializationError);
themeCatalogPersistenceService = new UnavailableThemeCatalogPersistenceService(
@@ -205,6 +217,12 @@ public sealed partial class MainWindow : Window
"Data",
"종목비교.dat"),
Path.Combine(AppContext.BaseDirectory, "Data", "종목비교.dat"));
ILegacyComparisonPairImportService? comparisonImporter =
initializationError is not null || _databaseRuntime is null
? null
: new LegacyComparisonPairImportService(
_databaseRuntime.Executor,
TrustedLegacyComparisonFileSource.CreateDefault());
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
_controller = new LegacyOperatorController(
stockLookup,
@@ -217,7 +235,8 @@ public sealed partial class MainWindow : Window
comparisonWorkflow: new LegacyComparisonWorkflowController(
new LegacyComparisonDataService(stockLookup, worldStockSearchService),
comparisonStore,
runtimeUiCatalogs.ComparisonLayout),
runtimeUiCatalogs.ComparisonLayout,
comparisonImporter),
manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
manualFinancialService,
manualFinancialStockSearchService),
@@ -230,10 +249,11 @@ public sealed partial class MainWindow : Window
expertSelectionService,
themeCatalogPersistenceService,
expertCatalogPersistenceService,
manualFinancialStockSearchService,
operatorCatalogStockSearchService,
stockMasterIdentityValidationService,
operatorCatalogSchemaValidationService),
cutRows: cutMenuComposition.Rows);
cutRows: cutMenuComposition.Rows,
fixedPagePlanProvider: fixedPagePlanProvider);
if (initializationError is not null)
{
_controller.ReportError(initializationError);
@@ -324,6 +344,18 @@ public sealed partial class MainWindow : Window
LegacyBridgeProtocol.TrustedHost,
webRoot,
CoreWebView2HostResourceAccessKind.DenyCors);
// The package keeps one persistent WebView2 profile while Debug MSIX
// payloads can be replaced without changing their public URL/version.
// Ignore that profile's HTTP cache for this WebView session so every
// launch reads index.html and all relative assets from this payload.
await coreWebView.CallDevToolsProtocolMethodAsync(
"Network.setCacheDisabled",
"{\"cacheDisabled\":true}");
if (_closing)
{
return;
}
coreWebView.Settings.AreDevToolsEnabled =
System.Diagnostics.Debugger.IsAttached;
coreWebView.Settings.AreBrowserAcceleratorKeysEnabled = false;
@@ -412,6 +444,7 @@ public sealed partial class MainWindow : Window
private async Task ProcessIntentAsync(LegacyUiIntent intent)
{
var gateEntered = false;
var orderedLocalSelectionIntentEntered = false;
var orderedPlayoutIntentEntered = false;
var databaseGateEntered = false;
var dispatchStarted = false;
@@ -428,10 +461,24 @@ public sealed partial class MainWindow : Window
_lifetimeCancellation.Token);
if (orderedPlayoutIntentEntered)
{
await _orderedLocalSelectionIntentGate.WaitAsync(
_lifetimeCancellation.Token);
orderedLocalSelectionIntentEntered = true;
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
gateEntered = true;
}
}
else if (IsQueuedLocalSelectionIntent(intent))
{
// WinForms handled these gestures on its one UI message queue. Keep
// their arrival order and let an in-progress automatic refresh finish
// instead of silently losing clicks, double-clicks, or keyboard focus.
await _orderedLocalSelectionIntentGate.WaitAsync(
_lifetimeCancellation.Token);
orderedLocalSelectionIntentEntered = true;
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
gateEntered = true;
}
else
{
// Keep the zero-timeout admission used by database and edit intents;
@@ -500,6 +547,15 @@ public sealed partial class MainWindow : Window
if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined())
{
if (intent is LegacyCutPointerDownIntent cutPointer)
{
// A ListView click is still a selection gesture even when its
// second click could have appended a row. Preserve selection
// and focus while stripping append authority in quarantine.
PostState(SelectCutWithoutAppend(cutPointer));
return;
}
PostState(ReportIntentFailure(
intent,
"이전 저장 결과가 불명확하여 이번 실행의 추가 변경이 차단되었습니다. DB 또는 로컬 파일을 읽기 전용으로 대조한 뒤 앱을 다시 시작하세요."));
@@ -516,11 +572,22 @@ public sealed partial class MainWindow : Window
return;
}
if (IsOperatorMutationIntent(intent) && !CanAcceptOperatorMutation())
if (IsOperatorMutationIntent(intent) &&
!CanAcceptOperatorMutation(intent, out var mutationRejectionMessage))
{
if (intent is LegacyCutPointerDownIntent cutPointer)
{
// An automatic refresh can start after the Web snapshot enabled
// the list but before native admission. Do not drop that click:
// apply selection/focus only and prevent a double-click append
// until the known busy interval has ended.
PostState(SelectCutWithoutAppend(cutPointer));
return;
}
PostState(ReportIntentFailure(
intent,
"PREPARE 또는 송출 중에는 플레이리스트와 운영 데이터를 변경할 수 없습니다. TAKE OUT 후 다시 시도하세요."));
mutationRejectionMessage));
return;
}
@@ -545,6 +612,8 @@ public sealed partial class MainWindow : Window
!(searchIntent.Trigger == LegacySearchTrigger.Button &&
searchIntent.Text.Length == 0)) ||
intent is LegacySelectTabIntent or
LegacyActivateFixedActionIntent or
LegacyActivateFixedSectionIntent or
LegacySearchThemesIntent or LegacySelectThemeIntent or
LegacyActivateThemeResultIntent or
LegacySearchExpertsIntent or LegacySelectExpertIntent or
@@ -561,6 +630,7 @@ public sealed partial class MainWindow : Window
LegacySearchOperatorCatalogIntent or
LegacySelectOperatorCatalogResultIntent or
LegacyBeginCreateThemeCatalogIntent or
LegacyChangeCreateThemeMarketIntent or
LegacyBeginCreateExpertCatalogIntent or
LegacySearchOperatorCatalogStocksIntent or
LegacyActivateOperatorCatalogStockIntent or
@@ -569,6 +639,7 @@ public sealed partial class MainWindow : Window
LegacySaveOperatorCatalogIntent or
LegacyConfirmNativeDialogIntent or
LegacyAddComparisonPairIntent or
LegacyImportComparisonPairsIntent or
LegacyMoveComparisonPairIntent or
LegacyDeleteSelectedComparisonPairIntent or
LegacyDeleteAllComparisonPairsIntent or
@@ -579,6 +650,7 @@ public sealed partial class MainWindow : Window
LegacyActivateManualFinancialResultIntent or
LegacySearchManualFinancialStocksIntent or
LegacySaveManualFinancialIntent or
LegacySaveManualFinancialRawIntent or
LegacyDeleteManualFinancialIntent or
LegacyDeleteAllManualFinancialIntent or
LegacyOpenManualListIntent or
@@ -658,6 +730,11 @@ public sealed partial class MainWindow : Window
_intentGate.Release();
}
if (orderedLocalSelectionIntentEntered)
{
_orderedLocalSelectionIntentGate.Release();
}
if (orderedPlayoutIntentEntered)
{
_orderedPlayoutIntentGate.Release();
@@ -665,9 +742,31 @@ public sealed partial class MainWindow : Window
}
}
private LegacyOperatorSnapshot SelectCutWithoutAppend(
LegacyCutPointerDownIntent pointer) =>
_controller.CutPointerDown(
pointer.PhysicalIndex,
pointer.Control,
pointer.Shift,
pointer.X,
pointer.Y,
pointer.TimestampMilliseconds,
allowAppend: false);
private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is
LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent;
private static bool IsQueuedLocalSelectionIntent(LegacyUiIntent intent) => intent is
LegacySelectStockIntent or
LegacySelectOperatorCatalogStockIntent or
LegacyActivateOperatorCatalogStockIntent or
LegacyCutPointerDownIntent or
LegacyCutKeyDownIntent or
LegacyClearCutSelectionIntent or
LegacySelectPlaylistRowIntent or
LegacyActivatePlaylistRowIntent or
LegacySelectPlaylistBoundaryIntent;
private static bool IsOperatorSettingsIntent(LegacyUiIntent intent) => intent is
LegacyChooseOperatorFolderIntent or
LegacyResetOperatorFolderIntent or
@@ -878,6 +977,8 @@ public sealed partial class MainWindow : Window
_controller.ClearComparisonTargets(),
LegacyAddComparisonPairIntent =>
await _controller.AddComparisonPairAsync(cancellationToken),
LegacyImportComparisonPairsIntent =>
_controller.RequestLegacyComparisonPairImport(),
LegacySelectComparisonPairIntent selection =>
_controller.SelectComparisonPair(selection.RowId),
LegacyMoveComparisonPairIntent move =>
@@ -961,6 +1062,11 @@ public sealed partial class MainWindow : Window
await _controller.BeginCreateThemeCatalogAsync(
create.Market,
cancellationToken),
LegacyChangeCreateThemeMarketIntent change =>
await _controller.ChangeCreateThemeCatalogMarketAsync(
change.Market,
change.EditorName,
cancellationToken),
LegacyBeginCreateExpertCatalogIntent =>
await _controller.BeginCreateExpertCatalogAsync(cancellationToken),
LegacySearchOperatorCatalogStocksIntent search =>
@@ -1030,6 +1136,7 @@ public sealed partial class MainWindow : Window
LegacySaveCurrentNamedPlaylistToIntent save =>
await _controller.SaveCurrentNamedPlaylistToAsync(
save.DefinitionId,
save.ShowSavedAlert,
cancellationToken),
LegacyDeleteSelectedNamedPlaylistIntent delete =>
await DeleteNamedPlaylistForDispatchAsync(
@@ -1073,6 +1180,10 @@ public sealed partial class MainWindow : Window
await _controller.SaveManualFinancialAsync(
save.Record,
cancellationToken),
LegacySaveManualFinancialRawIntent save =>
await _controller.SaveManualFinancialRawAsync(
save.Record,
cancellationToken),
LegacyDeleteManualFinancialIntent =>
await _controller.DeleteManualFinancialAsync(
all: false,
@@ -1358,6 +1469,9 @@ public sealed partial class MainWindow : Window
// 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.
// The original WinForms controls also followed GetDoubleClickTime;
// carrying it through the same settings-change path prevents a valid
// slow Windows double-click from being split into a single click.
return LegacyInteractionMetrics.FromPixelsAtDpi(
GetSystemMetricsForDpi(
SmCxDrag,
@@ -1366,7 +1480,8 @@ public sealed partial class MainWindow : Window
SmCyDrag,
LegacyInteractionMetrics.CssPixelsPerInch),
LegacyInteractionMetrics.CssPixelsPerInch,
ReadHostRasterizationScale());
hostRasterizationScale: ReadHostRasterizationScale(),
doubleClickTimeMilliseconds: (int)GetDoubleClickTime());
}
catch (DllNotFoundException)
{
@@ -1401,6 +1516,9 @@ public sealed partial class MainWindow : Window
[DllImport("user32.dll", ExactSpelling = true)]
private static extern int GetSystemMetricsForDpi(int nIndex, uint dpi);
[DllImport("user32.dll", ExactSpelling = true)]
private static extern uint GetDoubleClickTime();
[DllImport("comctl32.dll", ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowSubclass(
@@ -1502,6 +1620,12 @@ public sealed partial class MainWindow : Window
public bool CanMutate => false;
public Task<ThemeCatalogStoredResult> ReadStoredAsync(
ThemeSelectionIdentity identity,
CancellationToken cancellationToken = default) =>
Task.FromException<ThemeCatalogStoredResult>(
new InvalidOperationException(_message));
public Task<bool> ThemeNameExistsAsync(
ThemeMarket market,
string themeTitle,
@@ -1818,6 +1942,28 @@ public sealed partial class MainWindow : Window
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
}
private sealed class UnavailableOperatorCatalogStockSearchService :
IOperatorCatalogStockSearchService
{
private readonly string _message;
public UnavailableOperatorCatalogStockSearchService(string message)
{
_message = message;
}
public Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
public Task<StockSearchResult> SearchAllAsync(
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
}
private sealed class UnavailableOverseasStockSearchService : IOverseasStockSearchService
{
private readonly string _message;

File diff suppressed because it is too large Load Diff

View File

@@ -92,12 +92,12 @@
<span class="workspace-nav-label">테마</span>
</button>
<button class="workspace-nav-item workspace-market-item" type="button"
draggable="true" data-tab-id="overseasStocks" aria-label="해외종목" title="해외종목"
draggable="true" data-tab-id="overseasStocks" aria-label="해외시장" title="해외시장"
aria-current="false" aria-controls="catalog-workspace">
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
<circle cx="8" cy="9" r="4"></circle><path d="M3 19c.7-3 2.3-5 5-5s4.3 2 5 5M15 7h6M18 4v6M15 14h6M18 11v6"></path>
</svg>
<span class="workspace-nav-label">해외종목</span>
<span class="workspace-nav-label">해외시장</span>
</button>
<button class="workspace-nav-item workspace-market-item" type="button"
draggable="true" data-tab-id="expert" aria-label="전문가" title="전문가"
@@ -108,12 +108,12 @@
<span class="workspace-nav-label">전문가</span>
</button>
<button class="workspace-nav-item workspace-market-item" type="button"
draggable="true" data-tab-id="tradingHalt" aria-label="정지" title="정지"
draggable="true" data-tab-id="tradingHalt" aria-label="거래정지" title="거래정지"
aria-current="false" aria-controls="catalog-workspace">
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
<circle cx="12" cy="12" r="8"></circle><path d="M9.5 8v8M14.5 8v8"></path>
</svg>
<span class="workspace-nav-label">정지</span>
<span class="workspace-nav-label">거래정지</span>
</button>
</div>
</div>
@@ -313,9 +313,9 @@
<div class="settings-folder-copy">
<div class="settings-folder-title-line">
<h4 id="operator-background-folder-title">운영 배경 폴더</h4>
<span class="settings-folder-kind">VRV</span>
<span class="settings-folder-kind">VRV/JPG/PNG</span>
</div>
<p>F2에서 선택할 수 있는 검증된 .vrv 배경의 기준·허용 위치입니다.</p>
<p>F2에서 선택할 수 있는 검증된 VRV/JPG/PNG 배경의 기준·허용 위치입니다.</p>
<div class="settings-folder-value">
<strong id="operator-background-folder-name">앱 기본 배경 폴더</strong>
<span id="operator-background-folder-status" class="settings-status-badge neutral">확인 중</span>
@@ -393,6 +393,8 @@
</svg>
</div>
<div><small>항상 표시</small><h2>송출 스케줄</h2></div>
<div id="playlist-heading-count" class="playlist-heading-count"
role="status" aria-live="polite" aria-atomic="true">총 0개 · 송출 0개</div>
</header>
<div class="playlist-toolbar">
<label><input id="playlist-enable-all" type="checkbox" checked disabled> 전체</label>
@@ -494,6 +496,9 @@
<button id="manual-financial-close" type="button" aria-label="닫기">×</button>
</header>
<div id="manual-financial-workspace" class="manual-financial-workspace"></div>
<footer class="manual-dialog-footer">
<button id="manual-financial-exit" type="button">나가기</button>
</footer>
</section>
</div>
@@ -505,6 +510,9 @@
<button id="manual-list-close" type="button" aria-label="닫기">×</button>
</header>
<div id="manual-list-workspace" class="manual-list-workspace"></div>
<footer class="manual-dialog-footer">
<button id="manual-list-cancel" type="button">취소</button>
</footer>
</section>
</div>
@@ -545,6 +553,14 @@
aria-labelledby="operator-catalog-editor-title">
<div class="operator-catalog-panel-header">
<h3 id="operator-catalog-editor-title">추가·편집</h3>
<label id="operator-catalog-editor-market-field"
class="operator-catalog-editor-market" hidden>
<span>시장</span>
<select id="operator-catalog-editor-market" aria-label="새 ThemeA 시장" disabled>
<option value="krx">KRX</option>
<option value="nxt">NXT</option>
</select>
</label>
<span id="operator-catalog-code" class="operator-catalog-code"></span>
</div>
<div class="operator-catalog-name-field">

View File

@@ -23,7 +23,7 @@ button:disabled { color: #555; opacity: 1; }
.operator-shell {
display: grid;
grid-template-columns: minmax(900px, 2fr) minmax(600px, 1fr);
grid-template-columns: minmax(900px, 1.75fr) minmax(600px, 1fr);
width: 100%;
min-width: 1500px;
height: 100vh;
@@ -224,18 +224,27 @@ button:disabled { color: #555; opacity: 1; }
.manual-financial-backdrop { position: fixed; inset: 0; z-index: 18; background: rgba(0, 0, 0, .3); }
.manual-financial-backdrop[hidden] { display: none; }
.manual-financial-dialog { position: absolute; left: 50%; top: 50%; width: min(1120px, 92vw); height: min(800px, 90vh); transform: translate(-50%, -50%); display: grid; grid-template-rows: 46px minmax(0, 1fr); border: 1px solid #666; background: #f3f3f3; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
.manual-financial-dialog { position: absolute; left: 50%; top: 50%; width: min(1120px, 92vw); height: min(800px, 90vh); transform: translate(-50%, -50%); display: grid; grid-template-rows: 46px minmax(0, 1fr) auto; border: 1px solid #666; background: #f3f3f3; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
.manual-financial-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; background: #ececec; }
.manual-financial-dialog h2 { margin: 0; font-size: 18px; }
.manual-financial-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
.manual-financial-workspace { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 7px; padding: 8px; }
.manual-financial-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 5px; }
.manual-financial-toolbar input, .manual-financial-toolbar button { min-width: 0; height: 30px; }
.manual-financial-body { min-height: 0; display: grid; grid-template-columns: 270px minmax(0, 1fr); gap: 8px; }
.manual-financial-body { min-height: 0; display: grid; grid-template-columns: minmax(430px, .95fr) minmax(0, 1.05fr); gap: 8px; }
.manual-financial-list { min-height: 0; overflow: auto; border: 1px solid #999; background: #fff; }
.manual-financial-list button { display: block; width: 100%; min-height: 25px; padding: 3px 7px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
.manual-financial-list button.selected { background: #dcdcdc; }
.manual-financial-list button.manual-financial-row-unreadable { border-left: 3px solid #c4475d; background: #fff6f8; color: #92263a; }
.manual-financial-list button.manual-financial-row-unreadable::after { content: "읽기 불가"; position: absolute; top: 3px; right: 4px; padding: 1px 3px; background: inherit; font-size: 11px; font-weight: 700; }
.manual-financial-list button.manual-financial-row-legacy { border-left: 3px solid #b7791f; background: #fffaf0; color: #744210; }
.manual-financial-list button.manual-financial-row-legacy::after { content: "기존 원문"; position: absolute; top: 3px; right: 4px; padding: 1px 3px; background: inherit; font-size: 11px; font-weight: 700; }
.manual-financial-list button.manual-financial-list-header { position: sticky; top: 0; z-index: 1; background: #ececec; border-bottom: 1px solid #888; font-weight: 700; }
.manual-financial-list button.manual-financial-list-row { position: relative; display: grid; align-items: stretch; width: 100%; padding: 0; }
.manual-financial-preview-columns-6 button.manual-financial-list-row { min-width: 560px; grid-template-columns: minmax(112px, 1.35fr) repeat(5, minmax(82px, 1fr)); }
.manual-financial-preview-columns-7 button.manual-financial-list-row { min-width: 600px; grid-template-columns: minmax(112px, 1.35fr) repeat(6, minmax(76px, 1fr)); }
.manual-financial-preview-cell { min-width: 0; overflow: hidden; padding: 4px 6px; border-right: 1px solid #e2e2e2; text-overflow: ellipsis; white-space: nowrap; }
.manual-financial-preview-cell:last-child { border-right: 0; }
.manual-financial-editor { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 7px; padding: 8px; border: 1px solid #999; background: #fff; }
.manual-financial-stock-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; }
.manual-financial-stock-line input, .manual-financial-stock-line button { height: 30px; }
@@ -243,6 +252,9 @@ button:disabled { color: #555; opacity: 1; }
.manual-financial-stock-results button { padding: 3px 7px; }
.manual-financial-stock-results button.selected { border-color: #145ab4; background: #dceaff; }
.manual-financial-verification { min-height: 22px; color: #145ab4; }
.manual-financial-reference { align-self: center; width: min(100%, 360px); margin: 0; padding: 6px; border: 1px solid #ccc; background: #f7f7f7; }
.manual-financial-reference img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: contain; background: #111; }
.manual-financial-reference figcaption { margin-top: 5px; color: #555; font-size: 12px; text-align: center; }
.manual-financial-fields { display: grid; gap: 7px; }
.manual-financial-field { display: grid; grid-template-columns: 100px minmax(0, 1fr); align-items: center; gap: 6px; }
.manual-financial-field input { min-width: 0; height: 28px; }
@@ -257,7 +269,7 @@ button:disabled { color: #555; opacity: 1; }
.manual-financial-warning { padding: 7px; border: 1px solid #b00020; background: #fff1f3; color: #9b001b; font-weight: 700; }
.manual-list-backdrop { position: fixed; inset: 0; z-index: 19; background: rgba(0, 0, 0, .32); }
.manual-list-backdrop[hidden] { display: none; }
.manual-list-dialog { position: absolute; left: 50%; top: 50%; width: min(980px, 94vw); height: min(760px, 91vh); transform: translate(-50%, -50%); display: grid; grid-template-rows: 46px minmax(0, 1fr); border: 1px solid #666; background: #f3f3f3; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
.manual-list-dialog { position: absolute; left: 50%; top: 50%; width: min(980px, 94vw); height: min(760px, 91vh); transform: translate(-50%, -50%); display: grid; grid-template-rows: 46px minmax(0, 1fr) auto; border: 1px solid #666; background: #f3f3f3; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); }
.manual-list-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; }
.manual-list-dialog h2 { margin: 0; font-size: 18px; }
.manual-list-dialog > header button { width: 34px; height: 29px; font-size: 20px; }
@@ -274,9 +286,12 @@ button:disabled { color: #555; opacity: 1; }
.manual-list-search-results button.selected { background: #dcdcdc; font-weight: 600; }
.manual-list-status { padding: 7px; border: 1px solid #bbb; background: #fff; }
.manual-list-status.error { border-color: #b00020; background: #fff1f3; color: #9b001b; font-weight: 700; }
.manual-list-status.warning { border-color: #c68a00; background: #fff8e5; color: #765000; font-weight: 700; }
.manual-list-actions-row { margin-top: auto; justify-content: flex-end; }
.manual-list-actions-row button { min-width: 100px; height: 34px; }
.manual-list-actions-row .primary { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; }
.manual-dialog-footer { display: flex; justify-content: flex-end; padding: 9px 10px; border-top: 1px solid #aaa; background: #ececec; }
.manual-dialog-footer button { min-width: 100px; height: 34px; }
.cut-list { flex: 1; min-height: 0; border: 1px solid #999; background: #fff; overflow-y: auto; user-select: none; }
.cut-row { display: grid; grid-template-columns: 62px 1fr; min-height: 23px; border-bottom: 1px solid #ddd; background: #fff; font-size: 15px; }
@@ -286,7 +301,8 @@ button:disabled { color: #555; opacity: 1; }
.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; }
.cut-row.dragging { opacity: .58; cursor: not-allowed; }
.cut-row.dragging.drag-allowed { cursor: move; }
.playlist-grid.cut-drag-over { box-shadow: inset 0 0 0 3px #176b2d; }
.cut-drag-feedback { position: fixed; z-index: 10000; display: flex; align-items: center; gap: 7px; max-width: 260px; padding: 7px 10px; border: 1px solid #8a8a8a; border-radius: 4px; background: rgba(255, 255, 255, .97); box-shadow: 0 3px 10px rgba(0, 0, 0, .28); color: #555; font-size: 13px; font-weight: 700; line-height: 1; pointer-events: none; user-select: none; white-space: nowrap; }
.cut-drag-feedback[hidden] { display: none; }
@@ -371,8 +387,9 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; }
.overseas-results button { display: block; width: 100%; min-height: 22px; padding: 2px 6px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
.comparison-workspace { display: grid; grid-template-rows: minmax(110px, 22%) minmax(70px, 16%) minmax(150px, 1fr) auto; height: 100%; gap: 6px; }
.comparison-searches { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; gap: 6px; }
.comparison-search-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; }
.comparison-search-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-height: 0; }
.comparison-search-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px; }
.comparison-search-warning { color: #9a4b00; font-size: 11px; font-weight: 600; padding: 3px 1px 0; }
.comparison-results, .comparison-saved { min-height: 0; overflow: auto; border: 1px solid #aaa; background: #fff; }
.comparison-results button, .comparison-saved button { display: block; width: 100%; padding: 2px 5px; border: 0; background: #fff; text-align: left; }
.comparison-results button:hover, .comparison-results button.selected, .comparison-saved button.selected { background: #dcdcdc; }
@@ -380,10 +397,11 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; }
.comparison-fixed-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: auto; border: 1px solid #aaa; }
.comparison-fixed-list button { padding: 2px 4px; text-align: left; }
.comparison-fixed-list button.selected { background: #dcdcdc; font-weight: 600; }
.comparison-pair-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-height: 0; gap: 4px; }
.comparison-pair-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; min-height: 0; gap: 4px; }
.comparison-draft { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto auto; gap: 4px; }
.comparison-draft input { min-width: 0; }
.comparison-saved-tools { display: flex; gap: 4px; justify-content: flex-end; }
.comparison-import-status { min-height: 18px; color: var(--text-secondary, #4b5563); font-size: 11px; }
.comparison-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
.theme-workspace { display: grid; grid-template-rows: auto minmax(72px, .8fr) auto auto minmax(72px, 1fr); height: 100%; gap: 7px; }
.theme-search-line { display: grid; grid-template-columns: minmax(120px, 2.1fr) repeat(4, minmax(64px, 1fr)); gap: 5px; }
@@ -391,6 +409,7 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; }
.theme-results-panel, .theme-preview-panel { display: grid; min-height: 0; overflow: hidden; border: 1px solid #aaa; background: #fff; }
.theme-results-panel { grid-template-rows: auto minmax(0, 1fr); }
.theme-column-title { padding: 4px 6px; border-bottom: 1px solid #aaa; background: #eee; text-align: center; }
.theme-search-truncation { margin-left: 8px; color: #9a4b00; font-size: 11px; font-weight: 600; }
.theme-results { min-height: 0; overflow: auto; }
.theme-results button { display: block; width: 100%; padding: 3px 6px; border: 0; background: #fff; text-align: left; }
.theme-results button.selected { background: #dcdcdc; }
@@ -427,6 +446,9 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; }
.operator-catalog-list-panel { grid-template-rows: auto minmax(0, 1fr); }
.operator-catalog-panel-header { display: flex; align-items: center; min-height: 40px; gap: 6px; padding: 6px 8px; border-bottom: 1px solid #bbb; background: #f3f3f3; }
.operator-catalog-panel-header h3 { flex: 1; margin: 0; font-size: 15px; }
.operator-catalog-editor-market { display: inline-flex; align-items: center; gap: 5px; color: #555; font-size: 12px; }
.operator-catalog-editor-market[hidden] { display: none; }
.operator-catalog-editor-market select { min-width: 72px; }
.operator-catalog-code { color: #555; font-family: Consolas, monospace; }
.operator-catalog-results, .operator-catalog-stock-results, .operator-catalog-draft-rows { min-height: 0; overflow: auto; }
.operator-catalog-results button { display: grid; grid-template-columns: 84px minmax(0, 1fr); width: 100%; gap: 2px 8px; padding: 6px 8px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
@@ -520,7 +542,8 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; }
.playlist-data-row.on-air.selected { background: #dcdcdc; }
.playlist-data-row:focus { outline: 1px dotted #333; outline-offset: -2px; }
.playlist-data-row.dragging { opacity: .55; }
.playlist-data-row.dragging .playlist-row-header { cursor: grabbing; }
.playlist-data-row.dragging .playlist-row-header { cursor: not-allowed; }
.playlist-data-row.dragging.drag-allowed .playlist-row-header { cursor: move; }
.playlist-data-row.drag-before { box-shadow: inset 0 3px #176b2d; }
.playlist-data-row.drag-after { box-shadow: inset 0 -3px #176b2d; }
@@ -1218,6 +1241,23 @@ summary[data-tree-root]:focus-visible,
box-shadow: inset 4px 0 #d92d4b, inset 0 3px var(--ui-warning),
inset 0 0 0 2px rgba(37, 99, 235, .7);
}
.playlist-data-row.scene-unresolved .playlist-row-header {
color: #b54708;
}
.playlist-data-row.scene-unresolved .playlist-row-header::after {
content: "!";
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
margin-left: 2px;
border-radius: 50%;
background: #f79009;
color: #fff;
font-size: 9px;
line-height: 1;
}
.playlist-data-row.playout-current:not(.on-air):not([data-active="true"]) {
box-shadow: inset 0 3px var(--ui-warning);
}
@@ -1351,6 +1391,7 @@ summary[data-tree-root]:focus-visible,
color: #174ea6;
}
.named-playlist-dialog > footer,
.manual-dialog-footer,
.operator-catalog-toolbar {
border-color: var(--ui-border);
background: var(--ui-surface-subtle);
@@ -1781,6 +1822,321 @@ summary[data-tree-root]:focus-visible {
}
.settings-protected-copy h3 { margin-bottom: 5px; }
/* Contemporary density refinement: keep operator geometry, clarify visual hierarchy. */
:root {
--ui-canvas: #f3f6fa;
--ui-surface-subtle: #fafbfd;
--ui-surface-muted: #f2f5f8;
--ui-surface-hover: #eef4ff;
--ui-border: #dfe5ed;
--ui-control-border: #c4cedb;
--ui-radius-sm: 8px;
--ui-radius: 11px;
--ui-radius-lg: 16px;
--ui-shadow-sm: 0 1px 2px rgba(15, 23, 42, .04);
--ui-shadow-card: 0 1px 2px rgba(15, 23, 42, .055),
0 8px 24px rgba(15, 23, 42, .035);
}
button {
border-color: var(--ui-control-border);
box-shadow: none;
}
button:not(:disabled):hover {
border-color: #96a3b5;
box-shadow: 0 1px 2px rgba(15, 23, 42, .07);
}
button:not(:disabled):active { box-shadow: inset 0 1px 2px rgba(15, 23, 42, .08); }
.workspace-nav-header {
background: rgba(255, 255, 255, .72);
background-image: none;
}
.workspace-nav-items,
.workspace-navigation .market-tabs { gap: 2px; }
.workspace-nav-item,
.workspace-navigation .market-tabs button { border-radius: 9px; }
.workspace-nav-toggle svg,
.workspace-nav-item svg,
.workspace-navigation .market-tabs button svg { color: #5f6c80; }
.workspace-nav-item.workspace-current svg,
.workspace-navigation .market-tabs button.workspace-current svg { color: var(--ui-accent); }
.workspace-nav-section-label {
color: var(--ui-text-muted);
font-size: 11px;
}
.workspace-header,
.playlist-heading {
background: rgba(255, 255, 255, .97);
background-image: none;
box-shadow: 0 1px 0 rgba(15, 23, 42, .055);
backdrop-filter: saturate(140%) blur(12px);
}
.workspace-header::before {
right: auto;
left: 20px;
width: 72px;
height: 3px;
border-radius: 999px 999px 0 0;
background: linear-gradient(90deg, #f26b38 0 28%, var(--ui-accent) 28% 100%);
opacity: .88;
}
.playlist-heading::before {
right: auto;
left: 14px;
width: 58px;
height: 3px;
border-radius: 999px 999px 0 0;
background: var(--ui-accent);
opacity: .8;
}
.brand .brand-symbol { box-shadow: 0 5px 13px rgba(218, 78, 29, .18); }
.workspace-heading h1,
.playlist-heading h2 { letter-spacing: -.02em; }
.search-line {
width: min(100%, 660px);
min-height: 48px;
margin-bottom: 8px;
padding: 6px 8px;
border: 1px solid var(--ui-border);
border-radius: 12px;
background: rgba(255, 255, 255, .82);
box-shadow: var(--ui-shadow-sm);
}
.search-line button {
box-shadow: 0 5px 13px rgba(37, 99, 235, .18);
}
.stock-result,
.category-tree button,
.industry-list button,
.overseas-fixed-list button,
.overseas-results button,
.comparison-results button,
.comparison-saved button,
.comparison-fixed-list button,
.theme-results button,
.expert-results button,
.halt-results button,
.named-playlist-definitions button,
.manual-financial-list button,
.operator-catalog-results button,
.operator-catalog-stock-results button { font-weight: 500; }
.stock-result.selected {
background: var(--ui-accent-soft);
color: #174ea6;
box-shadow: inset 3px 0 var(--ui-accent);
font-weight: 700;
}
.industry-list button.selected,
.overseas-fixed-list button.selected,
.overseas-results button.selected,
.comparison-results button.selected,
.comparison-saved button.selected,
.comparison-fixed-list button.selected,
.theme-results button.selected,
.expert-results button.selected,
.halt-results button.selected,
.named-playlist-definitions button.selected,
.manual-financial-list button.selected,
.operator-catalog-results button.selected,
.operator-catalog-stock-results button.selected { font-weight: 650; }
.manual-actions button,
.manual-list-actions button {
border-color: var(--ui-control-border);
background: var(--ui-surface);
color: #344054;
}
.stock-results,
.cut-list,
.category-tree,
.playlist-grid,
.industry-list,
.overseas-fixed-list,
.overseas-results,
.comparison-results,
.comparison-saved,
.comparison-fixed-list,
.theme-results-panel,
.theme-preview-panel,
.expert-results,
.expert-preview,
.halt-results {
border-radius: 8px;
box-shadow: none;
}
.cut-row:nth-child(even):not(.separator):not(.selected) { background: #fbfcfe; }
.playlist-row,
.cut-row .cut-number,
.playout-status { font-variant-numeric: tabular-nums; }
.playlist-heading-count {
display: inline-flex;
min-height: 28px;
margin-left: auto;
align-items: center;
gap: 7px;
padding: 4px 9px;
border: 1px solid #cddbf3;
border-radius: 999px;
background: var(--ui-accent-soft);
color: #315a96;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.playlist-heading-count::before {
width: 6px;
height: 6px;
flex: 0 0 6px;
border-radius: 50%;
background: var(--ui-accent);
content: "";
}
.playlist-toolbar {
margin: 8px 0;
padding: 6px;
border: 1px solid var(--ui-border);
border-radius: 11px;
background: rgba(255, 255, 255, .82);
box-shadow: var(--ui-shadow-sm);
}
.playlist-toolbar label {
min-height: 27px;
padding: 2px 7px;
border-color: transparent;
border-radius: 8px;
background: #f5f7fa;
}
.playlist-toolbar label:has(input:checked) {
border-color: #c7d6f1;
background: var(--ui-accent-soft);
color: #315a96;
}
.playlist-toolbar button { height: 27px; padding-inline: 8px; border-radius: 8px; }
#playlist-move-up,
#playlist-move-down {
width: 28px;
min-width: 28px;
padding: 0;
border-color: transparent;
background: transparent;
box-shadow: none;
font-size: 14px;
}
#playlist-move-up:not(:disabled):hover,
#playlist-move-down:not(:disabled):hover {
border-color: var(--ui-control-border);
background: var(--ui-surface-muted);
}
#db-save-button { margin-left: 4px; }
#db-save-button:not(:disabled),
#db-load-button:not(:disabled) {
border-color: #bdcce5;
background: #f5f8ff;
color: #315a96;
}
.playlist-toolbar label:has(#playout-background-none) { margin-left: 4px; }
.background-name { background: #f8fafc; }
.playlist-grid { background: #f7f9fc; }
.playlist-header {
border-bottom-color: #cbd5e1;
background: #f8fafc;
}
.playlist-data-row > div { border-right-color: #eef2f6; }
.playlist-data-row:nth-child(even):not(.on-air):not([aria-selected="true"]):not([data-active="true"]):not(.playout-current) {
background: #fbfcfe;
}
.playlist-data-row:not(.on-air):not([aria-selected="true"]):not([data-active="true"]):hover {
background: #f1f5fb;
}
.playlist-rows { min-height: calc(100% - 29px); background: var(--ui-surface-subtle); }
.playlist-rows:empty {
display: grid;
place-items: center;
}
.playlist-rows:empty::after {
max-width: 260px;
padding: 16px;
color: #8a96a8;
content: "왼쪽 목록에서 컷을 더블클릭하거나 드래그해 추가하세요";
font-size: 12px;
line-height: 1.55;
text-align: center;
}
.playout-actions button { border-radius: 12px; }
.playout-actions .take-in:not(:disabled) {
border-color: #147a46;
background: #147a46;
box-shadow: 0 6px 16px rgba(20, 122, 70, .18);
color: #fff;
}
.playout-actions .take-in:not(:disabled):hover {
border-color: #106b3d;
background: #106b3d;
}
.playout-actions .next:not(:disabled) {
border-color: var(--ui-accent);
background: var(--ui-accent);
box-shadow: 0 6px 16px rgba(37, 99, 235, .18);
color: #fff;
}
.playout-actions .next:not(:disabled):hover {
border-color: var(--ui-accent-hover);
background: var(--ui-accent-hover);
}
.playout-actions .take-out:not(:disabled) {
border-color: #d35b70;
background: var(--ui-surface);
color: #ad2941;
}
.playout-actions .take-out:not(:disabled):hover {
border-color: var(--ui-danger);
background: var(--ui-danger-soft);
}
.playout-status {
justify-content: flex-start;
padding: 0 10px;
background: #f8fafc;
color: #475467;
}
.operator-status { color: var(--ui-text-muted); font-weight: 600; }
.named-playlist-backdrop,
.named-playlist-confirmation-backdrop,
.manual-financial-backdrop,
.manual-list-backdrop,
.operator-catalog-backdrop,
.dialog-backdrop {
background: rgba(15, 23, 42, .36);
backdrop-filter: blur(5px);
}
.named-playlist-dialog,
.named-playlist-confirmation-dialog,
.manual-financial-dialog,
.manual-list-dialog,
.operator-catalog-dialog,
.legacy-dialog { background: var(--ui-surface); }
.named-playlist-body,
.manual-financial-workspace,
.manual-list-workspace,
.operator-catalog-body { background: var(--ui-surface-subtle); }
.named-playlist-dialog > header button,
.manual-financial-dialog > header button,
.manual-list-dialog > header button,
.operator-catalog-dialog > header button {
width: 32px;
height: 32px;
border-radius: 999px;
}
.legacy-dialog { width: min(400px, calc(100vw - 40px)); padding: 28px; }
#legacy-dialog-message { line-height: 1.6; }
@media (max-width: 1720px) {
.settings-folder-row { grid-template-columns: 38px minmax(0, 1fr); }
.settings-folder-actions { grid-column: 2; }

View File

@@ -101,7 +101,7 @@
return true;
}
function syncMarket(tabId) {
function syncMarket(tabId, options) {
const button = marketButton(tabId);
if (!button) return false;
activeMarketTabId = tabId;
@@ -110,20 +110,42 @@
refreshCurrentMenu();
title.textContent = marketLabel(button);
}
if (pendingKeyboardMarketTabId && !button.disabled) {
const pendingButton = marketButton(pendingKeyboardMarketTabId);
const focusWasLost = !document.activeElement ||
document.activeElement === document.body ||
document.activeElement === document.documentElement;
if (activeWorkspace === "catalog" && pendingButton &&
!pendingButton.disabled && focusWasLost) {
pendingButton.focus();
}
const authoritativeNativeRender = options &&
options.authoritativeNativeRender === true;
if (authoritativeNativeRender && pendingKeyboardMarketTabId &&
pendingKeyboardMarketTabId !== tabId) {
// The native operation completed on a different tab. Do not carry a
// failed or superseded keyboard request into a later render.
pendingKeyboardMarketTabId = null;
}
if (authoritativeNativeRender && pendingKeyboardMarketTabId === tabId &&
!button.disabled) {
const pendingTabId = pendingKeyboardMarketTabId;
// Native loading temporarily disables the selected menu button, which
// can move focus to <body>. Only the final, non-busy native render may
// consume this request; the optimistic capture-phase click must not.
window.requestAnimationFrame(function () {
if (pendingKeyboardMarketTabId !== pendingTabId) return;
pendingKeyboardMarketTabId = null;
const pendingButton = marketButton(pendingTabId);
const active = document.activeElement;
const focusWasLost = !active ||
active === document.body ||
active === document.documentElement;
if (activeWorkspace === "catalog" && pendingButton &&
!pendingButton.disabled && focusWasLost) {
pendingButton.focus();
}
});
}
return true;
}
function hasPendingKeyboardMarketFocus(tabId) {
return pendingKeyboardMarketTabId !== null &&
(tabId === undefined || tabId === pendingKeyboardMarketTabId);
}
function select(name) {
if (views[name]) return setWorkspace(name);
if (!syncMarket(name)) return false;
@@ -154,8 +176,12 @@
event.preventDefault();
event.stopPropagation();
const keyboardMarketTabId = menuButton.dataset.tabId || null;
pendingKeyboardMarketTabId = keyboardMarketTabId;
menuButton.click();
if (keyboardMarketTabId) pendingKeyboardMarketTabId = keyboardMarketTabId;
return;
}
if (event.key === "Tab") {
pendingKeyboardMarketTabId = null;
return;
}
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
@@ -164,6 +190,12 @@
focusAdjacentItem(event.key === "ArrowUp" ? -1 : 1);
});
document.addEventListener("pointerdown", function () {
// A real pointer action transfers focus ownership to the user. A delayed
// native response must never pull it back to the keyboard-selected menu.
pendingKeyboardMarketTabId = null;
}, true);
// Reveal the selected catalog screen before app.js dispatches its native
// select-tab intent. The native state response remains authoritative and
// is reconciled through syncMarket after every render.
@@ -180,6 +212,7 @@
window.LegacyWorkspaceNavigation = Object.freeze({
select: select,
syncMarket: syncMarket,
hasPendingKeyboardMarketFocus: hasPendingKeyboardMarketFocus,
setExpanded: setNavigationExpanded,
active: function () { return activeWorkspace; },
activeMarket: function () { return activeMarketTabId; },