feat: complete legacy parity and modernize operator UI
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; },
|
||||
|
||||
Reference in New Issue
Block a user