feat: consolidate legacy parity migration baseline
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
|
||||
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private static readonly TimeSpan DatabaseHealthPollingInterval =
|
||||
TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly SemaphoreSlim _databaseActivityGate = new(1, 1);
|
||||
private readonly INativeOperatorLogWriter _databaseHealthLogWriter =
|
||||
new NativeOperatorLogWriter();
|
||||
private readonly NativeOperatorLogTransitionTracker _databaseHealthTransitions =
|
||||
new();
|
||||
private int _databaseActivityPriorityWaiterCount;
|
||||
private CancellationTokenSource? _databaseHealthCheckCancellation;
|
||||
private Task? _databaseHealthMonitorTask;
|
||||
|
||||
private void StartDatabaseHealthMonitor()
|
||||
{
|
||||
_databaseHealthMonitorTask ??=
|
||||
MonitorDatabaseHealthAsync(_lifetimeCancellation.Token);
|
||||
}
|
||||
|
||||
private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// WinForms timerAliveChecker first fired after its 10-second interval.
|
||||
await Task.Delay(DatabaseHealthPollingInterval, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await CheckAndLogDatabaseHealthAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckAndLogDatabaseHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Volatile.Read(ref _playoutBusy) != 0 ||
|
||||
Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0 ||
|
||||
!await _databaseActivityGate.WaitAsync(0, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationTokenSource? healthCancellation = null;
|
||||
try
|
||||
{
|
||||
if (Volatile.Read(ref _playoutBusy) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_databaseRuntime is null)
|
||||
{
|
||||
ObserveDatabaseHealthState(
|
||||
DataSourceKind.Oracle,
|
||||
DatabaseHealthState.NotConfigured);
|
||||
ObserveDatabaseHealthState(
|
||||
DataSourceKind.MariaDb,
|
||||
DatabaseHealthState.NotConfigured);
|
||||
return;
|
||||
}
|
||||
|
||||
healthCancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken);
|
||||
Interlocked.Exchange(
|
||||
ref _databaseHealthCheckCancellation,
|
||||
healthCancellation);
|
||||
|
||||
// Close the race where an operator command starts after this monitor
|
||||
// acquired the activity gate but before the provider probes begin.
|
||||
if (Volatile.Read(ref _playoutBusy) != 0 ||
|
||||
Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0)
|
||||
{
|
||||
healthCancellation.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
var statuses = await _databaseRuntime.HealthService
|
||||
.CheckAllAsync(healthCancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var status in statuses)
|
||||
{
|
||||
ObserveDatabaseHealthState(status.Source, status.State);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (
|
||||
cancellationToken.IsCancellationRequested ||
|
||||
healthCancellation?.IsCancellationRequested == true)
|
||||
{
|
||||
// Shutdown and operator work both have priority over diagnostics.
|
||||
}
|
||||
catch
|
||||
{
|
||||
ObserveDatabaseHealthState(
|
||||
DataSourceKind.Oracle,
|
||||
DatabaseHealthState.Unhealthy);
|
||||
ObserveDatabaseHealthState(
|
||||
DataSourceKind.MariaDb,
|
||||
DatabaseHealthState.Unhealthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (healthCancellation is not null)
|
||||
{
|
||||
Interlocked.CompareExchange(
|
||||
ref _databaseHealthCheckCancellation,
|
||||
null,
|
||||
healthCancellation);
|
||||
healthCancellation.Dispose();
|
||||
}
|
||||
|
||||
_databaseActivityGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcquirePriorityDatabaseActivityAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Signal priority before inspecting the active probe. This closes both
|
||||
// sides of the acquisition/publish race:
|
||||
// * a probe that has not acquired the gate sees the waiter and yields;
|
||||
// * a probe that owns the gate either sees the waiter after publishing
|
||||
// its CTS or is cancelled through that already-published CTS.
|
||||
Interlocked.Increment(ref _databaseActivityPriorityWaiterCount);
|
||||
try
|
||||
{
|
||||
CancelActiveDatabaseHealthCheck();
|
||||
await _databaseActivityGate.WaitAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _databaseActivityPriorityWaiterCount);
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelActiveDatabaseHealthCheck()
|
||||
{
|
||||
var healthCancellation = Volatile.Read(ref _databaseHealthCheckCancellation);
|
||||
try
|
||||
{
|
||||
healthCancellation?.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Probe completion won the race after the reference was read.
|
||||
}
|
||||
}
|
||||
|
||||
private void ObserveDatabaseHealthState(
|
||||
DataSourceKind source,
|
||||
DatabaseHealthState state)
|
||||
{
|
||||
if (!_databaseHealthTransitions.TryObserveDatabaseState(
|
||||
source,
|
||||
state,
|
||||
out var record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = _databaseHealthLogWriter.TryWrite(record);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logging is best effort and cannot affect DB or playout work.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,8 +178,9 @@ public sealed partial class MainWindow
|
||||
{
|
||||
case LegacyOperatorPlayoutCommand.Prepare:
|
||||
var request = gateAPrepareRequest ??
|
||||
_controller.CreatePlayoutRequest(
|
||||
_compositionOptions!.Current.FadeDuration);
|
||||
await CreateFreshPagedPlayoutRequestAsync(
|
||||
workflow,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
result = await workflow.PrepareAsync(
|
||||
request.Playlist,
|
||||
request.SelectedIndexZeroBased,
|
||||
@@ -190,8 +191,9 @@ public sealed partial class MainWindow
|
||||
// candidate) to m_crow and ran ONAirMode + Play, even while another
|
||||
// row was already on air. Re-read the current selection on every F8;
|
||||
// a stale explicit PREPARE must never override a later row click.
|
||||
var takeInRequest = _controller.CreatePlayoutRequest(
|
||||
_compositionOptions!.Current.FadeDuration);
|
||||
var takeInRequest = await CreateFreshPagedPlayoutRequestAsync(
|
||||
workflow,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
result = await workflow.TakeSelectedAsync(
|
||||
takeInRequest.Playlist,
|
||||
takeInRequest.SelectedIndexZeroBased,
|
||||
@@ -280,6 +282,61 @@ public sealed partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(
|
||||
LegacyPlayoutWorkflow workflow,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fadeDuration = _compositionOptions!.Current.FadeDuration;
|
||||
var request = _controller.CreatePlayoutRequest(fadeDuration);
|
||||
|
||||
// The first PREPARE/TAKE IN freezes the complete playlist. Resolve every
|
||||
// operator PageN row from a fresh read-only DB preflight before that snapshot
|
||||
// is handed to the workflow. A later F8 while PREPARED/PROGRAM reuses the
|
||||
// already-frozen 1/N values and must not query a second navigation contract.
|
||||
if (workflow.State.CurrentEntryId is not null)
|
||||
{
|
||||
return request;
|
||||
}
|
||||
|
||||
var pagedEntries = request.Playlist
|
||||
.Where(static entry => entry.PageNavigation?.PageSize.HasValue == true)
|
||||
.ToArray();
|
||||
if (pagedEntries.Length == 0)
|
||||
{
|
||||
return request;
|
||||
}
|
||||
|
||||
var plans = await workflow.PreflightPagePlansAsync(
|
||||
Array.AsReadOnly(pagedEntries),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (plans.Count != pagedEntries.Length)
|
||||
{
|
||||
throw new LegacySceneDataException(
|
||||
"The playlist page preflight did not return every PageN entry.");
|
||||
}
|
||||
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var entry = pagedEntries.SingleOrDefault(candidate =>
|
||||
string.Equals(candidate.EntryId, plan.EntryId, StringComparison.Ordinal));
|
||||
if (entry?.PageNavigation?.PageSize is not { } expectedPageSize ||
|
||||
plan.PageSize != (int)expectedPageSize ||
|
||||
plan.PageCount is < 1 or > ScenePaging.MaximumPageCount)
|
||||
{
|
||||
throw new LegacySceneDataException(
|
||||
"The playlist page preflight does not match the operator PageN row.");
|
||||
}
|
||||
|
||||
_controller.ReflectSuccessfulPlayout(
|
||||
plan.EntryId,
|
||||
pageIndexZeroBased: 0,
|
||||
pageCount: plan.PageCount,
|
||||
synchronizeOperatorSelection: false);
|
||||
}
|
||||
|
||||
return _controller.CreatePlayoutRequest(fadeDuration);
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync(
|
||||
string capability,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -596,9 +653,7 @@ public sealed partial class MainWindow
|
||||
}
|
||||
|
||||
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
|
||||
LegacyCutPointerDownIntent or
|
||||
LegacyCutKeyDownIntent or
|
||||
LegacyClearCutSelectionIntent or
|
||||
LegacyCutPointerDownIntent { AllowAppend: true } or
|
||||
LegacyDropSelectedCutsIntent or
|
||||
LegacySetPlaylistEnabledIntent or
|
||||
LegacySetAllPlaylistEnabledIntent or
|
||||
@@ -948,6 +1003,7 @@ public sealed partial class MainWindow
|
||||
}
|
||||
|
||||
var intentGateEntered = false;
|
||||
var databaseGateEntered = false;
|
||||
var playoutGateEntered = false;
|
||||
try
|
||||
{
|
||||
@@ -958,6 +1014,9 @@ public sealed partial class MainWindow
|
||||
_intentBusy = true;
|
||||
QueueOperatorState();
|
||||
|
||||
await AcquirePriorityDatabaseActivityAsync(token).ConfigureAwait(false);
|
||||
databaseGateEntered = true;
|
||||
|
||||
await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
|
||||
playoutGateEntered = true;
|
||||
Interlocked.Exchange(ref _playoutBusy, 1);
|
||||
@@ -1058,6 +1117,11 @@ public sealed partial class MainWindow
|
||||
_playoutCommandGate.Release();
|
||||
}
|
||||
|
||||
if (databaseGateEntered)
|
||||
{
|
||||
_databaseActivityGate.Release();
|
||||
}
|
||||
|
||||
if (intentGateEntered)
|
||||
{
|
||||
_intentBusy = false;
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
private readonly CancellationTokenSource _lifetimeCancellation = new();
|
||||
private readonly SemaphoreSlim _intentGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
|
||||
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
|
||||
private readonly WindowSubclassProcedure _windowSubclassProcedure;
|
||||
private readonly LegacyOperatorController _controller;
|
||||
@@ -241,6 +242,7 @@ public sealed partial class MainWindow : Window
|
||||
await InitializeWebViewAsync();
|
||||
if (!_closing)
|
||||
{
|
||||
StartDatabaseHealthMonitor();
|
||||
await ConnectPlayoutAsync(_lifetimeCancellation.Token);
|
||||
}
|
||||
}
|
||||
@@ -380,15 +382,35 @@ public sealed partial class MainWindow : Window
|
||||
private async Task ProcessIntentAsync(LegacyUiIntent intent)
|
||||
{
|
||||
var gateEntered = false;
|
||||
var orderedPlayoutIntentEntered = false;
|
||||
var databaseGateEntered = false;
|
||||
var dispatchStarted = false;
|
||||
try
|
||||
{
|
||||
// MainForm performed database search synchronously. The busy snapshot
|
||||
// freezes the Web controls, while this zero-timeout gate prevents an
|
||||
// already-posted duplicate from growing an unbounded DB request queue.
|
||||
gateEntered = await _intentGate.WaitAsync(
|
||||
0,
|
||||
_lifetimeCancellation.Token);
|
||||
// freezes the Web controls. Playout commands must retain their order behind
|
||||
// a preceding local row-selection intent, but only one may wait or execute
|
||||
// at a time so a double post can never become a queued vendor command.
|
||||
if (IsOrderedPlayoutIntent(intent))
|
||||
{
|
||||
orderedPlayoutIntentEntered = await _orderedPlayoutIntentGate.WaitAsync(
|
||||
0,
|
||||
_lifetimeCancellation.Token);
|
||||
if (orderedPlayoutIntentEntered)
|
||||
{
|
||||
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
gateEntered = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep the zero-timeout admission used by database and edit intents;
|
||||
// those requests must not build an unbounded queue behind slow I/O.
|
||||
gateEntered = await _intentGate.WaitAsync(
|
||||
0,
|
||||
_lifetimeCancellation.Token);
|
||||
}
|
||||
|
||||
if (!gateEntered)
|
||||
{
|
||||
PostState(IsNamedPlaylistModalIntent(intent)
|
||||
@@ -514,6 +536,13 @@ public sealed partial class MainWindow : Window
|
||||
PostState(_controller.Current);
|
||||
}
|
||||
|
||||
await AcquirePriorityDatabaseActivityAsync(_lifetimeCancellation.Token);
|
||||
databaseGateEntered = true;
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchStarted = true;
|
||||
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
|
||||
ObserveOperatorMutationQuarantine(state);
|
||||
@@ -546,13 +575,26 @@ public sealed partial class MainWindow : Window
|
||||
_intentBusy = false;
|
||||
}
|
||||
|
||||
if (databaseGateEntered)
|
||||
{
|
||||
_databaseActivityGate.Release();
|
||||
}
|
||||
|
||||
if (gateEntered)
|
||||
{
|
||||
_intentGate.Release();
|
||||
}
|
||||
|
||||
if (orderedPlayoutIntentEntered)
|
||||
{
|
||||
_orderedPlayoutIntentGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is
|
||||
LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent;
|
||||
|
||||
private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) =>
|
||||
intent is LegacyRefreshNamedPlaylistsIntent or
|
||||
LegacySelectNamedPlaylistIntent or
|
||||
@@ -648,7 +690,8 @@ public sealed partial class MainWindow : Window
|
||||
pointer.Shift,
|
||||
pointer.X,
|
||||
pointer.Y,
|
||||
pointer.TimestampMilliseconds),
|
||||
pointer.TimestampMilliseconds,
|
||||
pointer.AllowAppend),
|
||||
LegacyCutKeyDownIntent key => _controller.CutKeyDown(
|
||||
key.Key,
|
||||
key.Control,
|
||||
@@ -1347,6 +1390,7 @@ public sealed partial class MainWindow : Window
|
||||
DetachCloseConfirmation();
|
||||
DetachInteractionMetricsRefresh();
|
||||
_lifetimeCancellation.Cancel();
|
||||
CancelActiveDatabaseHealthCheck();
|
||||
ShutdownPlayoutRuntime();
|
||||
ShutdownManualListsRuntime();
|
||||
DataQueryExecutor.Reset();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const searchButton = document.getElementById("stock-search-button");
|
||||
const stockResults = document.getElementById("stock-results");
|
||||
const cutList = document.getElementById("cut-list");
|
||||
const cutDragFeedback = document.getElementById("cut-drag-feedback");
|
||||
const playlistRows = document.getElementById("playlist-rows");
|
||||
const playlistDropZone = document.getElementById("playlist-drop-zone");
|
||||
const playlistMoveUp = document.getElementById("playlist-move-up");
|
||||
@@ -123,6 +124,7 @@
|
||||
let operatorCatalogDraftKey = "";
|
||||
let manualResultClickTimer = null;
|
||||
let comparisonTargetClickTimer = null;
|
||||
let themeResultClickTimer = null;
|
||||
let manualViResultClickTimer = null;
|
||||
let operatorCatalogStockClickTimer = null;
|
||||
let pendingNamedPlaylistCommand = null;
|
||||
@@ -357,6 +359,8 @@
|
||||
treeFocusOwnedBeforeRender = categoryTree.contains(document.activeElement);
|
||||
if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId);
|
||||
if (nextTabId !== renderedTreeTabId) {
|
||||
clearDelayedClick(themeResultClickTimer);
|
||||
themeResultClickTimer = null;
|
||||
renderedTreeTabId = nextTabId;
|
||||
pendingTreeResetTabs.clear();
|
||||
if (nextTabId) pendingTreeResetTabs.add(nextTabId);
|
||||
@@ -461,6 +465,14 @@
|
||||
playout.isTakeOutCompletionPending === true || playout.phase !== "idle"));
|
||||
}
|
||||
|
||||
function isCutSelectionLocked(state) {
|
||||
const playout = state && state.playout;
|
||||
return !state || state.isBusy === true || state.fixedSectionBatch != null ||
|
||||
(playout && (playout.isBusy === true || playout.outcomeUnknown === true ||
|
||||
playout.isPlayCompletionPending === true ||
|
||||
playout.isTakeOutCompletionPending === true));
|
||||
}
|
||||
|
||||
function dragDropPosition(event, element) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after";
|
||||
@@ -669,10 +681,15 @@
|
||||
|
||||
function clearCutDragGesture(releaseCapture) {
|
||||
const gesture = cutDragGesture;
|
||||
if (!gesture) return;
|
||||
cutDragGesture = null;
|
||||
gesture.element.classList.remove("dragging");
|
||||
playlistDropZone.classList.remove("cut-drag-over");
|
||||
cutDragFeedback.hidden = true;
|
||||
cutDragFeedback.classList.remove("allowed");
|
||||
delete cutDragFeedback.dataset.dropAllowed;
|
||||
cutDragFeedback.style.removeProperty("left");
|
||||
cutDragFeedback.style.removeProperty("top");
|
||||
if (!gesture) return;
|
||||
gesture.element.classList.remove("dragging");
|
||||
if (releaseCapture !== false &&
|
||||
typeof gesture.element.hasPointerCapture === "function" &&
|
||||
gesture.element.hasPointerCapture(gesture.pointerId) &&
|
||||
@@ -685,6 +702,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showCutDragFeedback(event, allowed) {
|
||||
cutDragFeedback.hidden = false;
|
||||
cutDragFeedback.classList.toggle("allowed", allowed);
|
||||
cutDragFeedback.dataset.dropAllowed = allowed ? "true" : "false";
|
||||
const gap = 14;
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
const width = cutDragFeedback.offsetWidth;
|
||||
const height = cutDragFeedback.offsetHeight;
|
||||
const left = Math.max(4, Math.min(event.clientX + gap, viewportWidth - width - 4));
|
||||
const top = Math.max(4, Math.min(event.clientY + gap, viewportHeight - height - 4));
|
||||
cutDragFeedback.style.left = Math.round(left) + "px";
|
||||
cutDragFeedback.style.top = Math.round(top) + "px";
|
||||
}
|
||||
|
||||
function beginCutDragGesture(element, event, position) {
|
||||
clearCutDragGesture();
|
||||
if (event.button !== 0) return;
|
||||
@@ -719,9 +751,10 @@
|
||||
}
|
||||
if (gesture.started) {
|
||||
event.preventDefault();
|
||||
playlistDropZone.classList.toggle(
|
||||
"cut-drag-over",
|
||||
!playlistMutationLocked && pointInsideElement(playlistDropZone, event));
|
||||
const dropAllowed = !playlistMutationLocked &&
|
||||
pointInsideElement(playlistDropZone, event);
|
||||
playlistDropZone.classList.toggle("cut-drag-over", dropAllowed);
|
||||
showCutDragFeedback(event, dropAllowed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +823,6 @@
|
||||
element.append(number, label);
|
||||
|
||||
element.addEventListener("pointerdown", function (event) {
|
||||
if (playlistMutationLocked) return event.preventDefault();
|
||||
if (!cutSelectionEnabled) return event.preventDefault();
|
||||
focusCutElement(element, false);
|
||||
const position = pointerPosition(event);
|
||||
@@ -802,9 +834,12 @@
|
||||
// equality contract. Drag geometry keeps the raw fractional CSS point.
|
||||
x: Math.round(position.x),
|
||||
y: Math.round(position.y),
|
||||
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
|
||||
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp)),
|
||||
allowAppend: !playlistMutationLocked
|
||||
});
|
||||
beginCutDragGesture(element, event, position);
|
||||
if (!playlistMutationLocked) {
|
||||
beginCutDragGesture(element, event, position);
|
||||
}
|
||||
});
|
||||
element.addEventListener("pointermove", onCutPointerMove);
|
||||
element.addEventListener("pointerup", onCutPointerUp);
|
||||
@@ -816,7 +851,6 @@
|
||||
// The native ListView consumes these keys. Home/End still bubble to the
|
||||
// document-level MainForm KeyPreview equivalent for its playlist side effect.
|
||||
event.preventDefault();
|
||||
if (playlistMutationLocked) return;
|
||||
if (!cutSelectionEnabled) return;
|
||||
const target = cutKeyboardTarget(element, event.key);
|
||||
focusCutElement(target, true);
|
||||
@@ -831,7 +865,8 @@
|
||||
|
||||
function renderCuts(state) {
|
||||
const cutDragLocked = isOperatorMutationLocked(state);
|
||||
cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) && !cutDragLocked;
|
||||
cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) &&
|
||||
!isCutSelectionLocked(state);
|
||||
const focusedCutIndex = state.cutRows.findIndex(function (row) {
|
||||
return row.isFocused === true;
|
||||
});
|
||||
@@ -864,7 +899,9 @@
|
||||
? 0
|
||||
: -1;
|
||||
element.draggable = false;
|
||||
element.dataset.cutDragEnabled = cutSelectionEnabled ? "true" : "false";
|
||||
element.dataset.cutDragEnabled = cutSelectionEnabled && !cutDragLocked
|
||||
? "true"
|
||||
: "false";
|
||||
});
|
||||
|
||||
while (cutList.children.length > state.cutRows.length) {
|
||||
@@ -876,7 +913,7 @@
|
||||
}
|
||||
|
||||
cutList.addEventListener("pointerdown", function (event) {
|
||||
if (event.target !== cutList || playlistMutationLocked) return;
|
||||
if (event.target !== cutList || !cutSelectionEnabled) return;
|
||||
send("clear-cut-selection", {});
|
||||
});
|
||||
|
||||
@@ -4000,6 +4037,8 @@
|
||||
const themeResult = event.target.closest("button[data-theme-result-id]");
|
||||
if (themeResult && !themeResult.disabled) {
|
||||
event.preventDefault();
|
||||
clearDelayedClick(themeResultClickTimer);
|
||||
themeResultClickTimer = null;
|
||||
send("activate-theme-result", {
|
||||
resultId: themeResult.dataset.themeResultId
|
||||
});
|
||||
@@ -4260,7 +4299,12 @@
|
||||
}
|
||||
const themeResult = event.target.closest("button[data-theme-result-id]");
|
||||
if (themeResult) {
|
||||
send("select-theme", { resultId: themeResult.dataset.themeResultId });
|
||||
clearDelayedClick(themeResultClickTimer);
|
||||
const resultId = themeResult.dataset.themeResultId;
|
||||
themeResultClickTimer = window.setTimeout(function () {
|
||||
themeResultClickTimer = null;
|
||||
send("select-theme", { resultId: resultId });
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("button[data-theme-search]")) {
|
||||
|
||||
@@ -257,6 +257,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cut-drag-feedback" class="cut-drag-feedback" aria-hidden="true" hidden>
|
||||
<span class="cut-drag-feedback-icon"></span>
|
||||
<span>선택 컷 추가</span>
|
||||
</div>
|
||||
|
||||
<script src="legacy-cut-drag.js"></script>
|
||||
<script src="legacy-modal-focus.js"></script>
|
||||
<script src="legacy-named-playlist-selection.js"></script>
|
||||
|
||||
@@ -84,7 +84,7 @@ button:disabled { color: #555; opacity: 1; }
|
||||
.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: 38px minmax(0, 1fr); gap: 7px; padding: 8px; }
|
||||
.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; }
|
||||
@@ -144,6 +144,13 @@ button:disabled { color: #555; opacity: 1; }
|
||||
.cut-row[data-cut-drag-enabled="true"] { cursor: grab; }
|
||||
.cut-row.dragging { opacity: .58; cursor: grabbing; }
|
||||
.playlist-grid.cut-drag-over { box-shadow: inset 0 0 0 3px #176b2d; }
|
||||
.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; }
|
||||
.cut-drag-feedback-icon { display: inline-flex; width: 17px; height: 17px; align-items: center; justify-content: center; border-radius: 50%; background: #777; color: #fff; font-size: 16px; font-weight: 800; }
|
||||
.cut-drag-feedback-icon::before { content: "×"; }
|
||||
.cut-drag-feedback.allowed { border-color: #176b2d; color: #125824; }
|
||||
.cut-drag-feedback.allowed .cut-drag-feedback-icon { background: #176b2d; }
|
||||
.cut-drag-feedback.allowed .cut-drag-feedback-icon::before { content: "+"; }
|
||||
.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; }
|
||||
|
||||
.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; }
|
||||
|
||||
Reference in New Issue
Block a user