diff --git a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md
index 683ec7e..0230ce4 100644
--- a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md
+++ b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md
@@ -182,19 +182,20 @@
고정한 계약:
-- modal에서 처리한 Escape/F2/F3/F8은 뒤의 playout key listener로 전달하지 않는다. 이름 있는
- playlist modal의 Escape는 modal만 닫고 TAKE OUT을 보내지 않는다.
+- 원본 child form에는 `CancelButton`/`KeyPreview`/Escape handler가 없고 `ShowDialog`가 MainForm을
+ 비활성화한다. 따라서 modal의 Escape는 form을 닫지도 TAKE OUT을 보내지도 않으며,
+ F2/F3/F8과 playlist 전역 키도 owner로 전달하지 않는다.
- Delete는 원본 KeyPreview처럼 playlist 삭제 intent를 보내되 기본 키를 억제하지 않아,
텍스트 입력이 focus를 가진 경우 문자 삭제도 정상 동작한다.
- 시작 기본 배경은 `Cuts\video\기본.vrv`, F2/F3 기본 배경은 `Cuts\..\배경\기본.vrv`로
분리하고 두 신뢰 root가 서로 대신하지 않게 했다.
-- 화면에 원본 `DissolveTime` 1..20을 표시하고 C#에는 인덱스 0..19만 보낸다. 기본 인덱스는
- 6(화면 7)이며, PREPARE/F8 때 현재 전역 인덱스를 기존 playlist 모든 entry에 적용한다.
- 행 생성 시 저장된 기본 6이 전역 선택을 덮어쓰던 경로는 제거했다.
+- 원본 `DissolveTime`을 소유한 `panel3`은 Designer에서 항상 숨겨져 있으므로 WebView selector도
+ hidden/disabled로 유지하고 운영자 변경 intent를 노출하지 않는다. C#의 기존 0..19 인덱스와
+ 기본값 6은 scene effect 상태로만 보존한다.
## Slice 6: 탭 재진입과 dragover 교환
-상태: 코드 및 자동 테스트 완료, 실제 DB 쓰기/Tornado 명령은 실행하지 않음.
+상태: 코드 및 자동 테스트 완료, 실제 DB/Tornado 명령은 실행하지 않음.
원본 근거:
@@ -278,11 +279,61 @@
- PREPARED/PROGRAM/pending/unknown 또는 종목 미선택 상태에서는 컷 선택과 focus 이동을 함께
차단해 DOM과 C# 상태가 어긋나지 않게 한다.
+## Slice 10: modal 소유권·PList 완료 순서와 컷 drag 임계값
+
+상태: 코드·자동 테스트·unsigned Release package-context 시작/정상 종료 완료. 실제 컷 drag
+마우스 실측과 DB 쓰기/Tornado 명령은 실행하지 않음.
+
+원본 근거:
+
+- `Form/PList.cs:173-196`, `222-237` 행 double-click과 확인 버튼 save/load/close 순서
+- `Form/PList.cs:184`, `226`, `229`의 `저장하겠습니까?`, `저장되었습니다`
+- `Form/*Designer.cs`의 child form에 없는 `CancelButton`, `KeyPreview`, Escape handler
+- `MainForm.cs:3427-3459`, `3534-3549`, `3635-3643` 컷 mouse-down rectangle,
+ drag 시작과 체크 행 physical-order drop
+- `MainForm.Designer.cs:1391-1399` 숨겨진 `panel3`
+
+고정한 계약:
+
+- named playlist, GraphE, FSell/VI, ThemeA/EList modal은 opener를 기억하고 최초 legacy control에
+ focus하며 Tab/Shift+Tab을 modal 안에 가둔다. 중첩 alert를 닫으면 child modal의 이전 control로,
+ child modal을 닫으면 owner control로 focus를 돌려준다.
+- child modal의 Escape는 원본처럼 no-op으로 소비하고 MainForm TAKE OUT으로 새지 않는다.
+- PList save는 정확히 `저장하겠습니까?`를 묻는다. 확인 버튼 save 성공만
+ `저장되었습니다`를 표시하고, 행 double-click save는 성공 알림 없이 닫는다. No를 선택해도
+ 원본의 unconditional `Close()`처럼 modal을 닫는다.
+- 비동기 DB 명령 중에는 원본 동기 `ShowDialog` 호출이 반환되지 않던 구간처럼 close/cancel과
+ 중복 명령을 잠근다. 정확한 command/requestId/definition/sequence receipt만 modal을 닫으며
+ 실패 또는 결과 불명확 상태에서는 성공 알림·추정 close를 하지 않는 안전 교정을 유지한다.
+- `PList.Get_FileName_Folder`가 새 `DataSource`를 지정할 때 non-empty ListBox의 첫 행이 자동
+ 선택되는 동작을 열기·새로고침·추가·삭제 뒤 모두 유지한다. 다른 행 click은 즉시 시각 선택을
+ 바꾸되 exact native selection receipt 전에는 확인·삭제를 잠그고, double-click은 같은 opaque
+ definition id에 묶어 receipt 뒤 한 번만 실행하므로 이전 첫 행을 잘못 저장/삭제하지 않는다.
+- DB commit이 확정되고 read-back만 실패한 `CommittedOptimistic`은 원본처럼 저장 완료로
+ 처리하되 warning은 유지한다. 반대로 이전 성공 outcome이 남은 채 현재 입력 검증이 실패한
+ 경우에는 named workflow revision이 증가하지 않으므로 성공 receipt·알림·close를 만들지 않는다.
+- category tree 검색 input을 state render가 다시 만들더라도 id, caret/selection 범위와 focus를
+ 복원해 Enter 검색 뒤 원본 textbox의 연속 입력 동작을 유지한다.
+- 컷 drag rectangle은 `Point(x-SM_CXDRAG, y-SM_CYDRAG)`와 Windows drag size를 사용하고
+ left/top 포함, right/bottom 제외 `Rectangle.Contains` 판정을 그대로 적용한다. 브라우저 native
+ drag 임계값은 사용하지 않으며 pointer capture 뒤 playlist 경계 안에서 놓았을 때만 기존
+ `DropSelectedCutsOnPlaylist`를 한 번 호출한다.
+- `SM_CXDRAG/SM_CYDRAG`는 DPI 비인지 `GetSystemMetrics` 값을 그대로 쓰지 않는다.
+ `GetSystemMetricsForDpi(..., 96)`로 XAML host DIP 값을 읽고 `XamlRoot.RasterizationScale`도
+ state에 싣는다. Web은 이를 `window.devicePixelRatio`와 대조해 과거 profile에 남은 browser
+ zoom까지 제거한 pointer-event CSS pixel 임계값을 계산한다. 100%/150% DPI와 retained zoom을
+ 자동 테스트하며 비정상 scale 비율은 1로 fail-safe한다.
+- `WM_SETTINGCHANGE`/`WM_DISPLAYCHANGE`/`WM_DPICHANGED`와 모든 state 게시 경계에서 값을 다시
+ 읽어 시작 시 한 번만 캐시하지 않는다. cut-pointer-down 회신이 도착했을 때 아직 시작되지
+ 않은 pointer gesture의 rectangle도 최신 값으로 다시 만든다. 이후 browser/pinch zoom 입력은
+ 비활성화하고 API 실패나 0 이하는 축별 4 DIP로 fail-safe한다. 실제 drop 내용은 payload가
+ 아니라 C#의 현재 선택 집합과 physical 순서가 계속 권위본이다.
+
## 다음 대조 순서
-1. 컷 ListView의 drag 시작 임계값과 실제 package 키보드 회귀
-2. GraphE/FSell/VI/PList/AList/ThemeA/EList modal별 남은 키·focus·취소 이벤트
+1. 새 package에서 실제 마우스·키보드·컷/playlist drag 회귀
+2. ThemeA/EList 편집 save/delete/close의 세부 확인 문구와 focus 순서 대조
3. 자동 갱신 UI의 실제 실행 상태 기반 통합 테스트와 종료 상태 행렬
-4. Debug/Release x64와 새 package에서 실제 마우스·키보드·drag 회귀
+4. 다음 Designer handler 묶음의 원본 코드·상태 전이 대조
각 항목은 실제 원본 코드 근거와 회귀 테스트가 모두 생기기 전에는 완료로 판정하지 않는다.
diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md
index 51c36b6..92755ae 100644
--- a/docs/LEGACY_FEATURE_AUDIT.md
+++ b/docs/LEGACY_FEATURE_AUDIT.md
@@ -52,8 +52,8 @@ HELLO/BYE 연결은 2026-07-15에 확인했다. scene/PREPARE/TAKE/PGM은 실행
| LF-017 | P1 | 시스템 종료 확인 | 완료 | 원본 제목 `매일경제TV`, 중립 문구, 예/아니요와 기본 아니요를 유지하며 자동 TAKE OUT/반대 명령 금지 경계를 코드·자동 테스트로 유지 | 현재 Release package-context 회귀는 LF-005와 함께 판정 |
| LF-018 | P1 | 원본 비교쌍/수동 파일 | 완료 | `종목비교.dat` 8쌍 1회 import·재시작 marker, FSell 3×5행과 VI 9항목 trusted parser/store 검증 | 원본 파일은 계속 읽기 전용 |
| LF-019 | P1 | 실제 DB read→DTO→mutation | 완료 | Oracle/MariaDB 필수 scene selector 33/33, 전체 query 58건 PASS | 외부 asset/PGM 성공과 별도 |
-| LF-020 | P2 | FarPoint 대체 | 현재 빌드 실측 제외 | source 단일 행 drag, F8 후보·PROGRAM·DEL 집합·Shift anchor 분리, 종목 ListBox와 컷 ListView 키보드/selection mark를 DOM/C# 회귀로 고정. 탭 dragover와 EList reorder는 기존 실측 유지 | 현재 변경을 합친 package에서 playlist 단일 행 drag와 키보드/focus를 다시 실측; 활성 Print/Excel export는 원본에도 없음 |
-| LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 완료 | 두 번째 activation 전달, 강제 종료 없음. modal에서 F2/F3/F8/Escape와 playlist key 격리 | 새 MSIX 전체 회귀 |
+| LF-020 | P2 | FarPoint 대체 | 자동 검증 완료·package 실측 대기 | source 단일 행 drag, F8 후보·PROGRAM·DEL 집합·Shift anchor 분리, 종목 ListBox/컷 ListView 키보드와 Windows drag rectangle을 DOM/C# 회귀로 고정. 탭 dragover와 EList reorder는 기존 실측 유지 | 현재 변경을 합친 package에서 playlist 단일 행 drag와 키보드/focus를 다시 실측; 활성 Print/Excel export는 원본에도 없음 |
+| LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 자동 검증 완료·package 실측 대기 | 두 번째 activation 전달, 강제 종료 없음. child modal Escape no-op, 전역 키 격리, Tab trap, opener/nested-alert focus 복귀와 PList pending close lock을 고정 | 새 MSIX 전체 회귀 |
| LF-022 | P2 | 영구 운영 로그/bridge 오류 | 완료 | bounded/redacted log와 toast, malformed payload가 송출 상태를 바꾸지 않음 | authoritative Network Monitoring과 함께 판독 |
| LF-023 | P1 | NXT 저장 playlist restore | 외부 데이터 정합성 | stale code를 제목/current identity로 재검증하는 닫힌 경로는 완료. 현재 `v_all_stock` 결과가 비어 preview를 fail closed | DBA/data 정합성 없이는 direct-table 추정 금지 |
| LF-024 | P0 | Designer 이벤트 전수 감사 | 완료 | 원본 Designer 구독 **186개**, 화면별 고유 핸들러 **171개**를 IMPL 131/DEAD 20/VISUAL 10/DRAG 9/SAFETY 1로 전수 분류 | 상세 줄 단위 근거는 `LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md` |
diff --git a/docs/LEGACY_PARITY_VALIDATION_20260715.md b/docs/LEGACY_PARITY_VALIDATION_20260715.md
index bce31af..1fa46b2 100644
--- a/docs/LEGACY_PARITY_VALIDATION_20260715.md
+++ b/docs/LEGACY_PARITY_VALIDATION_20260715.md
@@ -170,25 +170,43 @@ drag만 5행 순서를 바꾸는 것도 확인했다. .NET Debug/Release 합계
scene PREPARE/TAKE/PGM을 실행하지 않은 이 Live 연결 회차를 전체 방송 송출 PASS로 확대하지
않는다.
-### 코드 대조 Slice 1~9 합본 추가 검증
+### 코드 대조 Slice 1~10 합본 추가 검증
이후 원본 이벤트를 다시 코드 단위로 대조해 F8 후보·PROGRAM·DEL 다중선택·Shift anchor를
분리하고, 런타임 컷 50행, 종목/컷 키보드, UC1 tree, s8010 이동평균 재샘플링, K3D raw 인자와
-OnCutOut/OnStopAll callback 게이트를 합쳤다. 이 합본의 검증 결과는 다음과 같다.
+ OnCutOut/OnStopAll callback 게이트에 이어 child modal focus/Escape, PList 완료 순서와
+ Windows 컷 drag rectangle을 합쳤다. 이 합본의 검증 결과는 다음과 같다.
| 항목 | 추가 검증 결과 |
|---|---|
-| .NET Debug x64 | **2,584/2,584** 통과, 실패·건너뜀 0 |
-| .NET Release x64 | **2,584/2,584** 통과, 실패·건너뜀 0 |
-| Web Node | **456/456** 통과, 실패·취소·건너뜀·TODO 0 |
+| .NET Debug x64 | **2,611/2,611** 통과, 실패·건너뜀 0 |
+| .NET Release x64 | **2,611/2,611** 통과, 실패·건너뜀 0 |
+| Web Node | **491/491** 통과, 실패·취소·건너뜀·TODO 0 |
| 솔루션 Debug/Release x64 | NuGet x64 복원 후 각각 경고 0 / 오류 0 |
-| Visual Studio 2026 MSBuild | VS **18.7.4**의 x64 MSBuild로 Debug/Release 솔루션 빌드 모두 성공 |
-| 최신 unsigned Release MSIX | **15,228,096 bytes**, SHA-256 `3D7FC0C0837756C860AC660DBCEA30BF543D261FE0EEEAE1CDA294FA95CC3BF1` |
-| 패키지 감사 | 75항목, `.t2s`·실설정·라이선스·인증서·K3D vendor 파일 0 |
-| package-context DryRun | 최신 Release layout 실행·응답 확인, 인자 없음, 앱 소유 TCP 0, 중립 종료창 `매일경제TV`/`예`/`아니요` 확인 후 정상 종료 |
+| Visual Studio 2026 MSBuild | VS **18.7.8**의 x64 MSBuild로 Debug/Release 솔루션 빌드 모두 성공 |
+| 최신 unsigned Release MSIX | **15,244,365 bytes**, SHA-256 `A2E9AA95B1F5E20C6E83A3B240A96F0E2792D3A1F809FEC60D829613BDF5CC8E` |
+| 패키지 감사 | 78항목, Web 7개 source byte-for-byte 일치, `.t2s`·실설정·라이선스·인증서·K3D vendor 파일 0 |
+| package-context DryRun | 최신 Release development registration에서 실행·응답, 인자 없음, `DRY RUN` UI, 앱 소유 TCP 0을 확인. 중립 종료창 `프로그램을 종료하겠습니까?`/`예`/`아니요`를 대조하고 `예`를 1회 Invoke해 정상 종료 |
이 unsigned MSIX는 개발 package-context 실행 검증용이며 위 signed 배포 패키지를 대체하지
않는다. 이 추가 회차에서도 scene/PREPARE/TAKE/NEXT/DB write와 Tornado 연결은 실행하지 않았다.
+새 컷 drag와 modal focus는 자동 계약까지 통과했으며 실제 package 마우스 drag 실측은 다음
+대조 회차에 남아 있다.
+
+컷 drag 임계값 후속 감사에서는 시작 시 한 번 읽던 `GetSystemMetrics` 캐시를 제거했다.
+현재 구현은 `GetSystemMetricsForDpi(SM_CXDRAG/SM_CYDRAG, 96)` 결과를 host DIP 단위로
+직렬화하고 `XamlRoot.RasterizationScale`/`window.devicePixelRatio` 비율로 pointer-event CSS
+pixel에 맞춘다. 따라서 100%·150% host DPI뿐 아니라 이전 profile에 남은 browser zoom도
+보정하며 비정상 비율은 1로 닫힌다. `WM_SETTINGCHANGE`·`WM_DISPLAYCHANGE`·`WM_DPICHANGED`와
+모든 state 게시 경계에서 값을 새로 읽고, cut-pointer-down 회신은 아직 시작되지 않은 gesture
+rectangle도 최신 값으로 다시 만든다. 이후 browser/pinch zoom 입력은 비활성화하며, native
+API를 사용할 수 없는 환경에서는 축별 4 DIP로 닫힌다.
+
+PList 후속 대조에서는 WinForms `DataSource` 재지정의 첫 행 자동 선택을 복원하고, 행 선택에도
+`named-ui-*` requestId와 exact command/target/sequence receipt를 적용했다. 따라서 2행 click
+직후 확인·삭제가 과거 1행에 실행되지 않으며, double-click은 동일 ID의 선택 완료 뒤 한 번만
+이어진다. known commit/read-back 실패는 warning을 유지한 성공으로, 현재 입력 검증 실패는
+revision 미증가 실패로 구분하는 자동 회귀도 통과했다.
## 판정 경계
diff --git a/docs/OPERATOR_UI_PARITY.md b/docs/OPERATOR_UI_PARITY.md
index e9cc3d1..5e1aa7d 100644
--- a/docs/OPERATOR_UI_PARITY.md
+++ b/docs/OPERATOR_UI_PARITY.md
@@ -120,6 +120,8 @@ Theme 결과 double-click은 원본의 5행 현재가 경로를 사용한다. PL
|---|---|---|
| FarPoint `AllowRowMove` | 내부 custom MIME와 opaque source/target `rowId`, before/after | 실제로 끌어 잡은 source 한 행만 이동한다. 수동 Ctrl/Shift 삭제 선택을 다중 행 drag로 확대하지 않으며, 체크박스에서 시작한 drag와 PREPARED/PROGRAM/pending/unknown 변경을 거부한다. |
| `tab_DragOver/MouseDown/Move/Up` | drop을 기다리지 않고 dragover로 `hover-swap-tabs` 전송 | C# 탭 identity와 `expectedSourceIndex/expectedTargetIndex`가 현재 순서와 일치할 때만 교환하므로 같은 메시지의 중복 전달이 순서를 되돌리지 않는다. |
+| 컷 `ListView.MouseDown/MouseMove` | pointer capture와 Windows `SM_CXDRAG/SM_CYDRAG` 비대칭 rectangle | Chromium native drag 임계값을 쓰지 않는다. DPI-aware API의 96-DPI 값을 host DIP로 전달하고 `XamlRoot.RasterizationScale`/`devicePixelRatio` 비율로 retained browser zoom까지 보정한다. Windows 설정·화면·DPI 변경 및 state 게시 때 갱신하며, left/top 포함·right/bottom 제외 경계를 넘은 뒤 playlist 안에서 놓을 때만 현재 C# 선택 집합을 physical order로 한 번 추가한다. |
+| PList `DataSource`/click/double-click | non-empty 목록 첫 행 자동 선택, 행 click 즉시 선택, double-click save/load | 열기·새로고침·추가·삭제 뒤 첫 행을 선택한다. 다른 행 click은 exact requestId 영수증 전까지 확인·삭제를 잠그고 동일 opaque ID의 double-click만 선택 완료 뒤 실행해 이전 행 DB mutation을 차단한다. |
| UC6 `FpSpread2.AllowDragDrop` | ThemeA/EList 편집행의 내부 custom MIME와 opaque `rowId` | 워크플로가 임의 before/after 위치를 계산한다. 존재하지 않는 ID, 같은 행, 추가 payload 필드는 strict bridge에서 거부하거나 no-op 처리한다. |
실제 마우스 검증에서는 playlist 행 drop이 조상 drop zone으로 버블링해 현재 선택된 컷을
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
index 6492c7f..432f876 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
@@ -3374,18 +3374,19 @@ public sealed class LegacyOperatorController
Touch();
return CreateSnapshot();
}
+ var selectedRevision = selected.NamedPlaylist?.Revision ?? -1;
var saved = await SaveCurrentNamedPlaylistAsync(cancellationToken)
.ConfigureAwait(false);
var succeeded =
+ (saved.NamedPlaylist?.Revision ?? -1) > selectedRevision &&
string.Equals(
saved.NamedPlaylist?.SelectedDefinitionId,
definitionId,
StringComparison.Ordinal) &&
saved.NamedPlaylist?.LastMutationOutcome is
LegacyNamedPlaylistMutationOutcome.CommittedFresh or
- LegacyNamedPlaylistMutationOutcome.CommittedOptimistic &&
- saved.StatusKind == LegacyOperatorStatusKind.Information;
+ LegacyNamedPlaylistMutationOutcome.CommittedOptimistic;
CompleteCommand(command, definitionId, succeeded);
Touch();
return CreateSnapshot();
@@ -3441,6 +3442,88 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ ///
+ /// Completes a correlated named-playlist command when the native dispatch
+ /// boundary rejects the request or encounters an unexpected exception.
+ /// The terminal receipt prevents the WebView modal from waiting indefinitely;
+ /// the named-playlist workflow remains the authority for any write quarantine
+ /// that was already latched before the exception escaped.
+ ///
+ public LegacyOperatorSnapshot ReportNamedPlaylistCommandFailure(
+ string command,
+ string targetId,
+ string message)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(command);
+ ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
+ if (command is not "refresh-named-playlists" and
+ not "select-named-playlist" and
+ not "create-named-playlist" and
+ not "delete-selected-named-playlist" and
+ not "load-named-playlist-by-id" and
+ not "save-current-named-playlist-to")
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(command),
+ command,
+ "Only correlated named-playlist commands can be completed here.");
+ }
+
+ CompleteCommand(command, targetId, succeeded: false);
+ return ReportError(message);
+ }
+
+ public LegacyOperatorSnapshot CompleteNamedPlaylistDispatch(
+ string command,
+ string requestId,
+ bool succeeded)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(command);
+ ArgumentException.ThrowIfNullOrWhiteSpace(requestId);
+ if (command is not "refresh-named-playlists" and
+ not "select-named-playlist" and
+ not "create-named-playlist" and
+ not "delete-selected-named-playlist")
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(command),
+ command,
+ "The command does not use a named-playlist dispatch id.");
+ }
+
+ CompleteCommand(command, requestId, succeeded);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ ///
+ /// Terminates an accepted save command without making its database outcome
+ /// retryable. This is intentionally separate from a known pre-dispatch
+ /// rejection: an escaped post-dispatch exception permanently quarantines every
+ /// later named-playlist write in this process.
+ ///
+ public LegacyOperatorSnapshot ReportNamedPlaylistWriteOutcomeUnknown(
+ string command,
+ string targetId,
+ string message)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(command);
+ ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
+ if (command is not "create-named-playlist" and
+ not "delete-selected-named-playlist" and
+ not "save-current-named-playlist-to")
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(command),
+ command,
+ "The command is not a named-playlist write.");
+ }
+
+ _namedPlaylistWorkflow?.QuarantineAmbiguousWriteOutcome();
+ CompleteCommand(command, targetId, succeeded: false);
+ return ReportError(message);
+ }
+
public LegacyOperatorSnapshot ReportInformation(string message)
{
_statusMessage = message ?? string.Empty;
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs
index cde4bc1..c5725f6 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs
@@ -297,6 +297,28 @@ public sealed class LegacyNamedPlaylistWorkflowController
}
}
+ ///
+ /// Conservatively latches the no-retry barrier when an exception escapes the
+ /// native dispatch boundary after a named-playlist write was accepted. The
+ /// caller cannot prove whether the database observed the mutation, so every
+ /// later write in this workflow instance must remain blocked until restart.
+ ///
+ public LegacyNamedPlaylistWorkflowSnapshot QuarantineAmbiguousWriteOutcome()
+ {
+ lock (_gate)
+ {
+ _isWriteQuarantined = true;
+ _lastMutationOutcome = LegacyNamedPlaylistMutationOutcome.OutcomeUnknown;
+ _statusMessage = QuarantineMessage;
+ _statusKind = LegacyOperatorStatusKind.Error;
+ ++_mutationEpoch;
+ ++_listGeneration;
+ ++_loadGeneration;
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
public async Task RefreshAsync(
int maximumResults = DefaultMaximumDefinitions,
CancellationToken cancellationToken = default)
@@ -854,9 +876,7 @@ public sealed class LegacyNamedPlaylistWorkflowController
definitions,
list.IsTruncated,
suggested,
- LegacyNamedPlaylistFreshness.Fresh,
- programCode);
- ApplyDocument(document, LegacyNamedPlaylistFreshness.Fresh);
+ LegacyNamedPlaylistFreshness.Fresh);
Touch();
}
@@ -968,10 +988,9 @@ public sealed class LegacyNamedPlaylistWorkflowController
IReadOnlyList summaries,
bool isTruncated,
string suggestedProgramCode,
- LegacyNamedPlaylistFreshness freshness,
- string? preferredSelectionCode = null)
+ LegacyNamedPlaylistFreshness freshness)
{
- var selectedCode = preferredSelectionCode ?? _selectedProgramCode;
+ var previousSelectedCode = _selectedProgramCode;
_definitions = summaries.Select(summary => new DefinitionState(
NextOpaqueId("named-definition"),
summary)).ToList();
@@ -979,12 +998,11 @@ public sealed class LegacyNamedPlaylistWorkflowController
_suggestedProgramCode = suggestedProgramCode;
_listFreshness = freshness;
- var selected = selectedCode is null
- ? null
- : _definitions.FirstOrDefault(definition => string.Equals(
- definition.Summary.ProgramCode,
- selectedCode,
- StringComparison.Ordinal));
+ // WinForms assigns a new DataSource on every PList refresh. A non-empty
+ // ListBox therefore selects index zero, including after add and delete.
+ // Keep that operator-visible reset instead of preserving a Web selection
+ // or selecting the newly created definition.
+ var selected = _definitions.FirstOrDefault();
if (selected is null)
{
_selectedProgramCode = null;
@@ -999,6 +1017,15 @@ public sealed class LegacyNamedPlaylistWorkflowController
_selectedProgramCode = selected.Summary.ProgramCode;
_selectedDefinitionId = selected.DefinitionId;
_selectedTitle = selected.Summary.Title;
+ if (!string.Equals(
+ previousSelectedCode,
+ selected.Summary.ProgramCode,
+ StringComparison.Ordinal))
+ {
+ _rows = [];
+ _documentFreshness = LegacyNamedPlaylistFreshness.None;
+ ++_loadGeneration;
+ }
}
private void ApplyDocument(
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
index bb99092..e463f4a 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
@@ -289,24 +289,92 @@ public sealed record LegacyConfirmManualListIntent : LegacyUiIntent;
public sealed record LegacyImportManualListsIntent : LegacyUiIntent;
-public sealed record LegacyRefreshNamedPlaylistsIntent : LegacyUiIntent;
+public sealed record LegacyRefreshNamedPlaylistsIntent(string RequestId) : LegacyUiIntent;
-public sealed record LegacySelectNamedPlaylistIntent(string DefinitionId) : LegacyUiIntent;
+public sealed record LegacySelectNamedPlaylistIntent(
+ string DefinitionId,
+ string RequestId) : LegacyUiIntent;
public sealed record LegacyLoadSelectedNamedPlaylistIntent : LegacyUiIntent;
public sealed record LegacyLoadNamedPlaylistByIdIntent(string DefinitionId) : LegacyUiIntent;
-public sealed record LegacyCreateNamedPlaylistIntent(string Title) : LegacyUiIntent;
+public sealed record LegacyCreateNamedPlaylistIntent(string Title, string RequestId) : LegacyUiIntent;
public sealed record LegacySaveCurrentNamedPlaylistIntent : LegacyUiIntent;
public sealed record LegacySaveCurrentNamedPlaylistToIntent(string DefinitionId) : LegacyUiIntent;
-public sealed record LegacyDeleteSelectedNamedPlaylistIntent : LegacyUiIntent;
+public sealed record LegacyDeleteSelectedNamedPlaylistIntent(string RequestId) : LegacyUiIntent;
public sealed record LegacyDismissDialogIntent : LegacyUiIntent;
+public sealed record LegacyInteractionMetrics(
+ int CutDragWidthDips,
+ int CutDragHeightDips,
+ uint SourceDpi = 96,
+ double HostRasterizationScale = 1d)
+{
+ public const uint CssPixelsPerInch = 96;
+ public const int FallbackCutDragWidth = 4;
+ public const int FallbackCutDragHeight = 4;
+
+ public static LegacyInteractionMetrics Default { get; } = new(
+ FallbackCutDragWidth,
+ FallbackCutDragHeight,
+ CssPixelsPerInch,
+ 1d);
+
+ public static LegacyInteractionMetrics FromPixelsAtDpi(
+ int cutDragWidthPixels,
+ int cutDragHeightPixels,
+ uint sourceDpi,
+ double hostRasterizationScale = 1d) =>
+ sourceDpi == 0
+ ? Default with
+ {
+ HostRasterizationScale = NormalizeHostScale(hostRasterizationScale)
+ }
+ : new(
+ ToDips(
+ cutDragWidthPixels,
+ sourceDpi,
+ FallbackCutDragWidth),
+ ToDips(
+ cutDragHeightPixels,
+ sourceDpi,
+ FallbackCutDragHeight),
+ sourceDpi,
+ NormalizeHostScale(hostRasterizationScale));
+
+ internal static LegacyInteractionMetrics Normalize(LegacyInteractionMetrics? metrics) =>
+ metrics is null
+ ? Default
+ : new(
+ metrics.CutDragWidthDips > 0
+ ? metrics.CutDragWidthDips
+ : FallbackCutDragWidth,
+ metrics.CutDragHeightDips > 0
+ ? metrics.CutDragHeightDips
+ : FallbackCutDragHeight,
+ metrics.SourceDpi > 0 ? metrics.SourceDpi : CssPixelsPerInch,
+ NormalizeHostScale(metrics.HostRasterizationScale));
+
+ private static double NormalizeHostScale(double hostRasterizationScale) =>
+ double.IsFinite(hostRasterizationScale) && hostRasterizationScale > 0d
+ ? hostRasterizationScale
+ : 1d;
+
+ private static int ToDips(int pixelsAtSourceDpi, uint sourceDpi, int fallback) =>
+ pixelsAtSourceDpi <= 0
+ ? fallback
+ : Math.Max(
+ 1,
+ (int)Math.Round(
+ pixelsAtSourceDpi * (double)CssPixelsPerInch / sourceDpi,
+ MidpointRounding.AwayFromZero));
+}
+
public static class LegacyBridgeProtocol
{
public const string TrustedHost = "legacy-parity.mbn.local";
@@ -466,9 +534,7 @@ public static class LegacyBridgeProtocol
"delete-operator-catalog" => ParseOperatorCatalogEmpty(
payload,
static () => new LegacyDeleteOperatorCatalogIntent()),
- "refresh-named-playlists" => ParseNamedPlaylistEmpty(
- payload,
- static () => new LegacyRefreshNamedPlaylistsIntent()),
+ "refresh-named-playlists" => ParseNamedPlaylistRefresh(payload),
"select-named-playlist" => ParseNamedPlaylistSelection(payload),
"load-named-playlist-by-id" => ParseNamedPlaylistLoadById(payload),
"load-selected-named-playlist" => ParseNamedPlaylistEmpty(
@@ -479,9 +545,8 @@ public static class LegacyBridgeProtocol
payload,
static () => new LegacySaveCurrentNamedPlaylistIntent()),
"save-current-named-playlist-to" => ParseNamedPlaylistSaveTo(payload),
- "delete-selected-named-playlist" => ParseNamedPlaylistEmpty(
- payload,
- static () => new LegacyDeleteSelectedNamedPlaylistIntent()),
+ "delete-selected-named-playlist" =>
+ ParseNamedPlaylistDelete(payload),
"open-manual-financial" => ParseManualFinancialOpen(payload),
"close-manual-financial" => ParseManualFinancialEmpty(
payload,
@@ -562,9 +627,12 @@ public static class LegacyBridgeProtocol
public static string SerializeState(
LegacyOperatorSnapshot snapshot,
bool isBusy = false,
- LegacyOperatorPlayoutSnapshot? playout = null)
+ LegacyOperatorPlayoutSnapshot? playout = null,
+ LegacyInteractionMetrics? interactionMetrics = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
+ var normalizedInteractionMetrics = LegacyInteractionMetrics.Normalize(
+ interactionMetrics);
var payload = new
{
snapshot.Revision,
@@ -1057,6 +1125,15 @@ public static class LegacyBridgeProtocol
playout.CanChangeBackground,
playout.Message
},
+ interactionMetrics = new
+ {
+ cutDragWidthDips = normalizedInteractionMetrics.CutDragWidthDips,
+ cutDragHeightDips = normalizedInteractionMetrics.CutDragHeightDips,
+ coordinateSpace = "host-dip",
+ sourceDpi = normalizedInteractionMetrics.SourceDpi,
+ hostRasterizationScale =
+ normalizedInteractionMetrics.HostRasterizationScale
+ },
isBusy
};
@@ -1824,10 +1901,19 @@ public static class LegacyBridgeProtocol
HasOnlyProperties(payload) ? factory() : null;
private static LegacyUiIntent? ParseNamedPlaylistSelection(JsonElement payload) =>
- HasOnlyProperties(payload, "definitionId") &&
+ HasOnlyProperties(payload, "definitionId", "requestId") &&
TryString(payload, "definitionId", 128, out var definitionId) &&
- IsSafeIdentifier(definitionId)
- ? new LegacySelectNamedPlaylistIntent(definitionId)
+ IsSafeIdentifier(definitionId) &&
+ TryString(payload, "requestId", 128, out var requestId) &&
+ IsSafeIdentifier(requestId)
+ ? new LegacySelectNamedPlaylistIntent(definitionId, requestId)
+ : null;
+
+ private static LegacyUiIntent? ParseNamedPlaylistRefresh(JsonElement payload) =>
+ HasOnlyProperties(payload, "requestId") &&
+ TryString(payload, "requestId", 128, out var requestId) &&
+ IsSafeIdentifier(requestId)
+ ? new LegacyRefreshNamedPlaylistsIntent(requestId)
: null;
private static LegacyUiIntent? ParseNamedPlaylistLoadById(JsonElement payload) =>
@@ -1845,7 +1931,7 @@ public static class LegacyBridgeProtocol
: null;
private static LegacyUiIntent? ParseNamedPlaylistCreate(JsonElement payload) =>
- HasOnlyProperties(payload, "title") &&
+ HasOnlyProperties(payload, "title", "requestId") &&
TrySafeString(
payload,
"title",
@@ -1853,8 +1939,17 @@ public static class LegacyBridgeProtocol
allowEmpty: false,
allowUnderscore: true,
out var title) &&
+ TryString(payload, "requestId", 128, out var requestId) &&
+ IsSafeIdentifier(requestId) &&
string.Equals(title, title.Trim(), StringComparison.Ordinal)
- ? new LegacyCreateNamedPlaylistIntent(title)
+ ? new LegacyCreateNamedPlaylistIntent(title, requestId)
+ : null;
+
+ private static LegacyUiIntent? ParseNamedPlaylistDelete(JsonElement payload) =>
+ HasOnlyProperties(payload, "requestId") &&
+ TryString(payload, "requestId", 128, out var requestId) &&
+ IsSafeIdentifier(requestId)
+ ? new LegacyDeleteSelectedNamedPlaylistIntent(requestId)
: null;
private static LegacyUiIntent? ParseManualFinancialOpen(JsonElement payload) =>
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
index abd5a6c..b081a4a 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
@@ -1,3 +1,4 @@
+using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
@@ -11,17 +12,31 @@ namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow : Window
{
+ private const int SmCxDrag = 68;
+ private const int SmCyDrag = 69;
+ private const uint WmSettingChange = 0x001A;
+ private const uint WmDisplayChange = 0x007E;
+ private const uint WmDpiChanged = 0x02E0;
+ private const nuint InteractionMetricsSubclassId = 0x4D424E49;
+
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
+ private readonly WindowSubclassProcedure _windowSubclassProcedure;
private readonly LegacyOperatorController _controller;
private readonly LegacyIndustrySelectionWorkflow _industryWorkflow;
private DatabaseRuntime? _databaseRuntime;
private bool _closing;
+ private bool _interactionMetricsRefreshQueued;
private bool _intentBusy;
+ private bool _windowSubclassAttached;
private bool _webViewReady;
+ private nint _windowHandle;
+ private LegacyInteractionMetrics _interactionMetrics =
+ LegacyInteractionMetrics.Default;
public MainWindow()
{
+ _windowSubclassProcedure = OnWindowSubclassMessage;
InitializeComponent();
EnsureCloseConfirmationAttached();
var localApplicationData = Environment.GetFolderPath(
@@ -202,6 +217,9 @@ public sealed partial class MainWindow : Window
{
EnsureCloseConfirmationAttached();
var windowHandle = WindowNative.GetWindowHandle(this);
+ _windowHandle = windowHandle;
+ AttachInteractionMetricsRefresh();
+ RefreshInteractionMetrics();
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
var appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Title = "V-Stock 증권정보송출시스템 for 매일경제TV (26.03.26)";
@@ -244,8 +262,11 @@ public sealed partial class MainWindow : Window
CoreWebView2HostResourceAccessKind.DenyCors);
coreWebView.Settings.AreDevToolsEnabled =
System.Diagnostics.Debugger.IsAttached;
+ coreWebView.Settings.AreBrowserAcceleratorKeysEnabled = false;
coreWebView.Settings.AreDefaultContextMenusEnabled = false;
coreWebView.Settings.IsStatusBarEnabled = false;
+ coreWebView.Settings.IsZoomControlEnabled = false;
+ coreWebView.Settings.IsPinchZoomEnabled = false;
coreWebView.NavigationStarting += OnNavigationStarting;
coreWebView.NavigationCompleted += OnNavigationCompleted;
coreWebView.NewWindowRequested += OnNewWindowRequested;
@@ -327,6 +348,7 @@ public sealed partial class MainWindow : Window
private async Task ProcessIntentAsync(LegacyUiIntent intent)
{
var gateEntered = false;
+ var dispatchStarted = false;
try
{
// MainForm performed database search synchronously. The busy snapshot
@@ -337,7 +359,11 @@ public sealed partial class MainWindow : Window
_lifetimeCancellation.Token);
if (!gateEntered)
{
- PostState(_controller.Current);
+ PostState(IsNamedPlaylistModalIntent(intent)
+ ? ReportIntentFailure(
+ intent,
+ "다른 요청을 처리 중이어서 DB 재생목록 요청을 시작하지 않았습니다.")
+ : _controller.Current);
return;
}
@@ -348,7 +374,8 @@ public sealed partial class MainWindow : Window
if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined())
{
- PostState(_controller.ReportError(
+ PostState(ReportIntentFailure(
+ intent,
"이전 저장 결과가 불명확하여 이번 실행의 추가 변경이 차단되었습니다. DB 또는 로컬 파일을 읽기 전용으로 대조한 뒤 앱을 다시 시작하세요."));
return;
}
@@ -357,14 +384,16 @@ public sealed partial class MainWindow : Window
IsOperatorMutationIntent(intent) &&
!IsFixedSectionBatchMutationIntent(intent))
{
- PostState(_controller.ReportError(
+ PostState(ReportIntentFailure(
+ intent,
"고정 컷 섹션의 수동 입력 중에는 현재 입력 단계만 변경할 수 있습니다. 완료하거나 취소한 뒤 다른 편성 작업을 실행하세요."));
return;
}
if (IsOperatorMutationIntent(intent) && !CanAcceptOperatorMutation())
{
- PostState(_controller.ReportError(
+ PostState(ReportIntentFailure(
+ intent,
"PREPARE 또는 송출 중에는 플레이리스트와 운영 데이터를 변경할 수 없습니다. TAKE OUT 후 다시 시도하세요."));
return;
}
@@ -433,6 +462,7 @@ public sealed partial class MainWindow : Window
PostState(_controller.Current);
}
+ dispatchStarted = true;
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
ObserveOperatorMutationQuarantine(state);
if (!_closing)
@@ -450,8 +480,12 @@ public sealed partial class MainWindow : Window
catch
{
_intentBusy = false;
- PostState(_controller.ReportError(
- "요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요."));
+ var failureState = ReportIntentFailure(
+ intent,
+ "요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요.",
+ writeOutcomeMayBeUnknown: dispatchStarted);
+ ObserveOperatorMutationQuarantine(failureState);
+ PostState(failureState);
}
finally
{
@@ -467,6 +501,82 @@ public sealed partial class MainWindow : Window
}
}
+ private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) =>
+ intent is LegacyRefreshNamedPlaylistsIntent or
+ LegacySelectNamedPlaylistIntent or
+ LegacyLoadSelectedNamedPlaylistIntent or
+ LegacyLoadNamedPlaylistByIdIntent or
+ LegacyCreateNamedPlaylistIntent or
+ LegacySaveCurrentNamedPlaylistIntent or
+ LegacySaveCurrentNamedPlaylistToIntent or
+ LegacyDeleteSelectedNamedPlaylistIntent;
+
+ private LegacyOperatorSnapshot ReportIntentFailure(
+ LegacyUiIntent intent,
+ string message,
+ bool writeOutcomeMayBeUnknown = false) =>
+ intent switch
+ {
+ LegacyRefreshNamedPlaylistsIntent refresh
+ when !string.IsNullOrWhiteSpace(refresh.RequestId) =>
+ _controller.ReportNamedPlaylistCommandFailure(
+ "refresh-named-playlists",
+ refresh.RequestId,
+ message),
+ LegacySelectNamedPlaylistIntent selection
+ when !string.IsNullOrWhiteSpace(selection.RequestId) =>
+ _controller.ReportNamedPlaylistCommandFailure(
+ "select-named-playlist",
+ selection.RequestId,
+ message),
+ LegacyCreateNamedPlaylistIntent create
+ when writeOutcomeMayBeUnknown &&
+ !string.IsNullOrWhiteSpace(create.RequestId) =>
+ _controller.ReportNamedPlaylistWriteOutcomeUnknown(
+ "create-named-playlist",
+ create.RequestId,
+ message),
+ LegacyCreateNamedPlaylistIntent create
+ when !string.IsNullOrWhiteSpace(create.RequestId) =>
+ _controller.ReportNamedPlaylistCommandFailure(
+ "create-named-playlist",
+ create.RequestId,
+ message),
+ LegacyDeleteSelectedNamedPlaylistIntent delete
+ when writeOutcomeMayBeUnknown &&
+ !string.IsNullOrWhiteSpace(delete.RequestId) =>
+ _controller.ReportNamedPlaylistWriteOutcomeUnknown(
+ "delete-selected-named-playlist",
+ delete.RequestId,
+ message),
+ LegacyDeleteSelectedNamedPlaylistIntent delete
+ when !string.IsNullOrWhiteSpace(delete.RequestId) =>
+ _controller.ReportNamedPlaylistCommandFailure(
+ "delete-selected-named-playlist",
+ delete.RequestId,
+ message),
+ LegacyLoadNamedPlaylistByIdIntent load
+ when !string.IsNullOrWhiteSpace(load.DefinitionId) =>
+ _controller.ReportNamedPlaylistCommandFailure(
+ "load-named-playlist-by-id",
+ load.DefinitionId,
+ message),
+ LegacySaveCurrentNamedPlaylistToIntent save
+ when writeOutcomeMayBeUnknown &&
+ !string.IsNullOrWhiteSpace(save.DefinitionId) =>
+ _controller.ReportNamedPlaylistWriteOutcomeUnknown(
+ "save-current-named-playlist-to",
+ save.DefinitionId,
+ message),
+ LegacySaveCurrentNamedPlaylistToIntent save
+ when !string.IsNullOrWhiteSpace(save.DefinitionId) =>
+ _controller.ReportNamedPlaylistCommandFailure(
+ "save-current-named-playlist-to",
+ save.DefinitionId,
+ message),
+ _ => _controller.ReportError(message)
+ };
+
private async Task HandleIntentAsync(
LegacyUiIntent intent,
CancellationToken cancellationToken)
@@ -714,10 +824,14 @@ public sealed partial class MainWindow : Window
cancellationToken),
LegacyDeleteOperatorCatalogIntent =>
await _controller.DeleteOperatorCatalogAsync(cancellationToken),
- LegacyRefreshNamedPlaylistsIntent =>
- await _controller.RefreshNamedPlaylistsAsync(cancellationToken),
+ LegacyRefreshNamedPlaylistsIntent refresh =>
+ await RefreshNamedPlaylistsForDispatchAsync(
+ refresh.RequestId,
+ cancellationToken),
LegacySelectNamedPlaylistIntent selection =>
- _controller.SelectNamedPlaylist(selection.DefinitionId),
+ SelectNamedPlaylistForDispatch(
+ selection.DefinitionId,
+ selection.RequestId),
LegacyLoadSelectedNamedPlaylistIntent =>
await _controller.LoadSelectedNamedPlaylistAsync(cancellationToken),
LegacyLoadNamedPlaylistByIdIntent load =>
@@ -725,8 +839,9 @@ public sealed partial class MainWindow : Window
load.DefinitionId,
cancellationToken),
LegacyCreateNamedPlaylistIntent create =>
- await _controller.CreateNamedPlaylistAsync(
+ await CreateNamedPlaylistForDispatchAsync(
create.Title,
+ create.RequestId,
cancellationToken),
LegacySaveCurrentNamedPlaylistIntent =>
await _controller.SaveCurrentNamedPlaylistAsync(cancellationToken),
@@ -734,8 +849,10 @@ public sealed partial class MainWindow : Window
await _controller.SaveCurrentNamedPlaylistToAsync(
save.DefinitionId,
cancellationToken),
- LegacyDeleteSelectedNamedPlaylistIntent =>
- await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken),
+ LegacyDeleteSelectedNamedPlaylistIntent delete =>
+ await DeleteNamedPlaylistForDispatchAsync(
+ delete.RequestId,
+ cancellationToken),
LegacyOpenManualFinancialIntent manual =>
await _controller.OpenManualFinancialAsync(
manual.Screen,
@@ -835,18 +952,280 @@ public sealed partial class MainWindow : Window
};
}
+ private async Task RefreshNamedPlaylistsForDispatchAsync(
+ string requestId,
+ CancellationToken cancellationToken)
+ {
+ var state = await _controller.RefreshNamedPlaylistsAsync(cancellationToken);
+ var succeeded = state.NamedPlaylist?.ReadStatus ==
+ LegacyNamedPlaylistReadStatus.Ready;
+ return _controller.CompleteNamedPlaylistDispatch(
+ "refresh-named-playlists",
+ requestId,
+ succeeded);
+ }
+
+ private LegacyOperatorSnapshot SelectNamedPlaylistForDispatch(
+ string definitionId,
+ string requestId)
+ {
+ var state = _controller.SelectNamedPlaylist(definitionId);
+ var succeeded = string.Equals(
+ state.NamedPlaylist?.SelectedDefinitionId,
+ definitionId,
+ StringComparison.Ordinal);
+ return _controller.CompleteNamedPlaylistDispatch(
+ "select-named-playlist",
+ requestId,
+ succeeded);
+ }
+
+ private async Task CreateNamedPlaylistForDispatchAsync(
+ string title,
+ string requestId,
+ CancellationToken cancellationToken)
+ {
+ var previousRevision = _controller.Current.NamedPlaylist?.Revision ?? -1;
+ var state = await _controller.CreateNamedPlaylistAsync(title, cancellationToken);
+ var succeeded = (state.NamedPlaylist?.Revision ?? -1) > previousRevision &&
+ IsCommittedNamedPlaylistMutation(state.NamedPlaylist?.LastMutationOutcome);
+ return _controller.CompleteNamedPlaylistDispatch(
+ "create-named-playlist",
+ requestId,
+ succeeded);
+ }
+
+ private async Task DeleteNamedPlaylistForDispatchAsync(
+ string requestId,
+ CancellationToken cancellationToken)
+ {
+ var previousRevision = _controller.Current.NamedPlaylist?.Revision ?? -1;
+ var state = await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken);
+ var succeeded = (state.NamedPlaylist?.Revision ?? -1) > previousRevision &&
+ IsCommittedNamedPlaylistMutation(state.NamedPlaylist?.LastMutationOutcome);
+ return _controller.CompleteNamedPlaylistDispatch(
+ "delete-selected-named-playlist",
+ requestId,
+ succeeded);
+ }
+
+ private static bool IsCommittedNamedPlaylistMutation(
+ LegacyNamedPlaylistMutationOutcome? outcome) =>
+ outcome is LegacyNamedPlaylistMutationOutcome.CommittedFresh or
+ LegacyNamedPlaylistMutationOutcome.CommittedOptimistic;
+
private void PostState(LegacyOperatorSnapshot snapshot)
{
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)
{
+ // SystemInformation.DragSize was evaluated on every original
+ // ListView.MouseDown. Refreshing at every native-to-web state boundary,
+ // in addition to the settings/DPI message hook, prevents a startup-only
+ // value from surviving a runtime Windows preference change.
+ RefreshInteractionMetrics();
Browser.CoreWebView2.PostWebMessageAsJson(
LegacyBridgeProtocol.SerializeState(
snapshot,
_intentBusy,
- CreatePlayoutSnapshot()));
+ CreatePlayoutSnapshot(),
+ interactionMetrics: _interactionMetrics));
}
}
+ private void AttachInteractionMetricsRefresh()
+ {
+ if (_windowSubclassAttached || _windowHandle == 0)
+ {
+ return;
+ }
+
+ try
+ {
+ _windowSubclassAttached = SetWindowSubclass(
+ _windowHandle,
+ _windowSubclassProcedure,
+ InteractionMetricsSubclassId,
+ 0);
+ }
+ catch (DllNotFoundException)
+ {
+ }
+ catch (EntryPointNotFoundException)
+ {
+ }
+ catch (BadImageFormatException)
+ {
+ }
+ catch (PlatformNotSupportedException)
+ {
+ }
+ }
+
+ private void DetachInteractionMetricsRefresh()
+ {
+ if (!_windowSubclassAttached || _windowHandle == 0)
+ {
+ return;
+ }
+
+ try
+ {
+ RemoveWindowSubclass(
+ _windowHandle,
+ _windowSubclassProcedure,
+ InteractionMetricsSubclassId);
+ }
+ catch (DllNotFoundException)
+ {
+ }
+ catch (EntryPointNotFoundException)
+ {
+ }
+ catch (BadImageFormatException)
+ {
+ }
+ catch (PlatformNotSupportedException)
+ {
+ }
+ finally
+ {
+ _windowSubclassAttached = false;
+ }
+ }
+
+ private nint OnWindowSubclassMessage(
+ nint windowHandle,
+ uint message,
+ nuint wParam,
+ nint lParam,
+ nuint subclassId,
+ nuint referenceData)
+ {
+ if (message is WmSettingChange or WmDisplayChange or WmDpiChanged)
+ {
+ QueueInteractionMetricsRefresh();
+ }
+
+ return DefSubclassProc(windowHandle, message, wParam, lParam);
+ }
+
+ private void QueueInteractionMetricsRefresh()
+ {
+ if (_closing || _interactionMetricsRefreshQueued)
+ {
+ return;
+ }
+
+ _interactionMetricsRefreshQueued = true;
+ if (!DispatcherQueue.TryEnqueue(
+ Microsoft.UI.Dispatching.DispatcherQueuePriority.High,
+ () =>
+ {
+ _interactionMetricsRefreshQueued = false;
+ if (_closing)
+ {
+ return;
+ }
+
+ RefreshInteractionMetrics();
+ if (_webViewReady)
+ {
+ PostState(_controller.Current);
+ }
+ }))
+ {
+ _interactionMetricsRefreshQueued = false;
+ }
+ }
+
+ private void RefreshInteractionMetrics()
+ {
+ _interactionMetrics = ReadSystemInteractionMetrics();
+ }
+
+ private LegacyInteractionMetrics ReadSystemInteractionMetrics()
+ {
+ try
+ {
+ // GetSystemMetrics returns physical pixels and is not DPI-aware.
+ // Asking the DPI-aware API for its 96-DPI values produces the same
+ // logical DIP coordinate space used by XAML. The bridge also carries
+ // XamlRoot.RasterizationScale so JavaScript can account for a zoom
+ // value retained by an older WebView profile without private API use.
+ return LegacyInteractionMetrics.FromPixelsAtDpi(
+ GetSystemMetricsForDpi(
+ SmCxDrag,
+ LegacyInteractionMetrics.CssPixelsPerInch),
+ GetSystemMetricsForDpi(
+ SmCyDrag,
+ LegacyInteractionMetrics.CssPixelsPerInch),
+ LegacyInteractionMetrics.CssPixelsPerInch,
+ ReadHostRasterizationScale());
+ }
+ catch (DllNotFoundException)
+ {
+ return CreateFallbackInteractionMetrics();
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return CreateFallbackInteractionMetrics();
+ }
+ catch (BadImageFormatException)
+ {
+ return CreateFallbackInteractionMetrics();
+ }
+ catch (PlatformNotSupportedException)
+ {
+ return CreateFallbackInteractionMetrics();
+ }
+ }
+
+ private LegacyInteractionMetrics CreateFallbackInteractionMetrics() =>
+ LegacyInteractionMetrics.Default with
+ {
+ HostRasterizationScale = ReadHostRasterizationScale()
+ };
+
+ private double ReadHostRasterizationScale()
+ {
+ var scale = Root.XamlRoot?.RasterizationScale ?? 1d;
+ return double.IsFinite(scale) && scale > 0d ? scale : 1d;
+ }
+
+ [DllImport("user32.dll", ExactSpelling = true)]
+ private static extern int GetSystemMetricsForDpi(int nIndex, uint dpi);
+
+ [DllImport("comctl32.dll", ExactSpelling = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetWindowSubclass(
+ nint windowHandle,
+ WindowSubclassProcedure subclassProcedure,
+ nuint subclassId,
+ nuint referenceData);
+
+ [DllImport("comctl32.dll", ExactSpelling = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool RemoveWindowSubclass(
+ nint windowHandle,
+ WindowSubclassProcedure subclassProcedure,
+ nuint subclassId);
+
+ [DllImport("comctl32.dll", ExactSpelling = true)]
+ private static extern nint DefSubclassProc(
+ nint windowHandle,
+ uint message,
+ nuint wParam,
+ nint lParam);
+
+ [UnmanagedFunctionPointer(CallingConvention.Winapi)]
+ private delegate nint WindowSubclassProcedure(
+ nint windowHandle,
+ uint message,
+ nuint wParam,
+ nint lParam,
+ nuint subclassId,
+ nuint referenceData);
+
private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args)
{
ObserveOperatorMutationQuarantine(
@@ -882,6 +1261,7 @@ public sealed partial class MainWindow : Window
Root.Loaded -= OnRootLoaded;
Closed -= OnClosed;
DetachCloseConfirmation();
+ DetachInteractionMetricsRefresh();
_lifetimeCancellation.Cancel();
ShutdownPlayoutRuntime();
ShutdownManualListsRuntime();
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js
index 5cb5bb4..4f7bac8 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js
@@ -34,6 +34,14 @@
const namedPlaylistDeleteButton = document.getElementById("named-playlist-delete-button");
const namedPlaylistCancelButton = document.getElementById("named-playlist-cancel-button");
const namedPlaylistConfirmButton = document.getElementById("named-playlist-confirm-button");
+ const namedPlaylistConfirmationModal = document.getElementById(
+ "named-playlist-confirmation");
+ const namedPlaylistConfirmationMessage = document.getElementById(
+ "named-playlist-confirmation-message");
+ const namedPlaylistConfirmationYes = document.getElementById(
+ "named-playlist-confirmation-yes");
+ const namedPlaylistConfirmationNo = document.getElementById(
+ "named-playlist-confirmation-no");
const manualButtons = Array.from(document.querySelectorAll("button[data-manual-screen]"));
const manualModal = document.getElementById("manual-financial-modal");
const manualTitle = document.getElementById("manual-financial-title");
@@ -73,9 +81,33 @@
const operatorCatalogDelete = document.getElementById("operator-catalog-delete");
const operatorCatalogCancel = document.getElementById("operator-catalog-cancel");
const operatorCatalogSave = document.getElementById("operator-catalog-save");
+ const namedPlaylistDialog = namedPlaylistModal.querySelector("[role='dialog']");
+ const namedPlaylistConfirmationDialog = namedPlaylistConfirmationModal.querySelector(
+ "[role='alertdialog']");
+ const manualDialog = manualModal.querySelector("[role='dialog']");
+ const manualListDialog = manualListModal.querySelector("[role='dialog']");
+ const operatorCatalogDialog = operatorCatalogModal.querySelector("[role='dialog']");
+ const legacyAlertDialog = dialog.querySelector("[role='alertdialog']");
+ if (!window.LegacyModalFocus || typeof window.LegacyModalFocus.create !== "function") {
+ throw new Error("Legacy modal focus support was not loaded.");
+ }
+ if (!window.LegacyCutDrag ||
+ typeof window.LegacyCutDrag.createDragRectangle !== "function" ||
+ typeof window.LegacyCutDrag.shouldStart !== "function" ||
+ typeof window.LegacyCutDrag.toCssDragSize !== "function") {
+ throw new Error("Legacy cut drag support was not loaded.");
+ }
+ if (!window.LegacyNamedPlaylistSelection ||
+ typeof window.LegacyNamedPlaylistSelection.create !== "function") {
+ throw new Error("Legacy named-playlist selection support was not loaded.");
+ }
+ const modalFocusManager = window.LegacyModalFocus.create({ document: document });
+ const namedPlaylistSelection = window.LegacyNamedPlaylistSelection.create();
+ modalFocusManager.start();
let uiBusy = false;
let namedPlaylistMode = null;
let namedPlaylistState = null;
+ let namedPlaylistConfirmation = null;
let manualDraft = null;
let manualDraftKey = "";
let operatorCatalogState = null;
@@ -86,10 +118,10 @@
let comparisonTargetClickTimer = null;
let manualViResultClickTimer = null;
let operatorCatalogStockClickTimer = null;
- let namedPlaylistResultClickTimer = null;
let pendingNamedPlaylistCommand = null;
+ let nextNamedPlaylistRequestId = 1;
let lastCommandSequence = 0;
- const selectedCutsDragToken = "legacy-selected-cuts";
+ let operatorMutationLocked = true;
const playlistDragMime = "text/x-mbn-playlist-row";
const operatorCatalogDragMime = "text/x-mbn-operator-catalog-row";
let draggedPlaylistRowId = null;
@@ -97,6 +129,8 @@
let playlistDragBlockedRowId = null;
let playlistMutationLocked = false;
let cutSelectionEnabled = false;
+ let cutDragSize = { width: 4, height: 4 };
+ let cutDragGesture = null;
let lastPresentedPlayoutRowKey = "";
let draggedTabId = null;
let tabDragLastTargetId = null;
@@ -106,9 +140,80 @@
let operatorCatalogMutationLocked = false;
let renderedTreeTabId = null;
let treeInteractionLocked = false;
+ let treeFocusOwnedBeforeRender = false;
const treeUiStateByTab = new Map();
const pendingTreeResetTabs = new Set();
+ function preferredModalFocus(modal) {
+ if (modal === legacyAlertDialog) return dialogOk;
+ if (modal === namedPlaylistConfirmationDialog) return namedPlaylistConfirmationYes;
+ if (modal === namedPlaylistDialog) {
+ return namedPlaylistDefinitions.querySelector("button[aria-selected='true']") ||
+ namedPlaylistDefinitions.querySelector("button") || namedPlaylistNewTitle;
+ }
+ if (modal === manualDialog) {
+ return manualWorkspace.querySelector("button.selected[data-manual-result-id]") ||
+ manualWorkspace.querySelector("button[data-manual-result-id]") ||
+ manualWorkspace.querySelector(".manual-financial-list-header");
+ }
+ if (modal === manualListDialog) {
+ return manualListWorkspace.querySelector("input:not([disabled])") ||
+ manualListWorkspace.querySelector("button:not([disabled])");
+ }
+ if (modal === operatorCatalogDialog) {
+ if (!operatorCatalogEditor.hidden && !operatorCatalogName.disabled) {
+ return operatorCatalogName;
+ }
+ return operatorCatalogResults.querySelector("button[aria-selected='true']") ||
+ operatorCatalogResults.querySelector("button") || operatorCatalogQuery;
+ }
+ return null;
+ }
+
+ function createModalOpenerReference() {
+ const opener = document.activeElement;
+ if (!renderedTreeTabId || !categoryTree.contains(opener)) return opener;
+
+ captureTreeUiState(renderedTreeTabId);
+ const node = opener.closest("[data-tree-node-key]");
+ const descriptor = {
+ tabId: renderedTreeTabId,
+ controlId: opener.id || null,
+ nodeKey: node ? node.dataset.treeNodeKey : null,
+ selectionStart: Number.isInteger(opener.selectionStart) ? opener.selectionStart : null,
+ selectionEnd: Number.isInteger(opener.selectionEnd) ? opener.selectionEnd : null
+ };
+ return function resolveTreeModalOpener() {
+ if (descriptor.tabId !== renderedTreeTabId) return null;
+ let resolved = descriptor.controlId
+ ? document.getElementById(descriptor.controlId) : null;
+ if (!resolved && descriptor.nodeKey) {
+ resolved = findTreeNodeByKey(descriptor.nodeKey);
+ }
+ if (!resolved || !categoryTree.contains(resolved) || resolved.disabled === true) {
+ return null;
+ }
+ if (Number.isInteger(descriptor.selectionStart) &&
+ Number.isInteger(descriptor.selectionEnd) &&
+ typeof resolved.setSelectionRange === "function") {
+ try {
+ resolved.setSelectionRange(descriptor.selectionStart, descriptor.selectionEnd);
+ } catch (_) {
+ // A recreated non-text control may expose, but reject, selection ranges.
+ }
+ }
+ return resolved;
+ };
+ }
+
+ function rememberModalOpener(modal) {
+ if (modal) modalFocusManager.rememberOpener(modal, createModalOpenerReference());
+ }
+
+ function syncModalFocus() {
+ modalFocusManager.sync(preferredModalFocus);
+ }
+
function activeTreeTabId(state) {
if (!state || !Array.isArray(state.tabs)) return null;
const active = state.tabs.find(function (tab) { return tab.isActive === true; });
@@ -214,19 +319,33 @@
});
const selected = categoryTree.querySelector(
"[data-tree-node-key].tree-node-selected");
- const active = categoryTree.contains(document.activeElement)
- ? document.activeElement.closest("[data-tree-node-key]")
+ const activeElement = categoryTree.contains(document.activeElement)
+ ? document.activeElement
+ : null;
+ const active = activeElement
+ ? activeElement.closest("[data-tree-node-key]")
+ : null;
+ const controlFocus = activeElement && activeElement.id
+ ? {
+ id: activeElement.id,
+ selectionStart: Number.isInteger(activeElement.selectionStart)
+ ? activeElement.selectionStart : null,
+ selectionEnd: Number.isInteger(activeElement.selectionEnd)
+ ? activeElement.selectionEnd : null
+ }
: null;
treeUiStateByTab.set(tabId, {
openSections: openSections,
selectedKey: selected ? selected.dataset.treeNodeKey : previous.selectedKey || null,
- focusKey: active ? active.dataset.treeNodeKey : null,
+ focusKey: active ? active.dataset.treeNodeKey : previous.focusKey || null,
+ controlFocus: activeElement ? controlFocus : previous.controlFocus || null,
scrollTop: categoryTree.scrollTop
});
}
function beginTreeRender(state) {
const nextTabId = activeTreeTabId(state);
+ treeFocusOwnedBeforeRender = categoryTree.contains(document.activeElement);
if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId);
if (nextTabId !== renderedTreeTabId) {
renderedTreeTabId = nextTabId;
@@ -250,7 +369,7 @@
syncTreeSectionExpanded(section);
});
const firstRoot = roots[0];
- selectTreeNode(firstRoot, true);
+ selectTreeNode(firstRoot, !modalFocusManager.getActiveModal());
if (typeof firstRoot.scrollIntoView === "function") {
firstRoot.scrollIntoView({ block: "nearest", inline: "nearest" });
}
@@ -278,15 +397,41 @@
}
const selected = findTreeNodeByKey(saved.selectedKey);
if (selected) selectTreeNode(selected, false);
+ const canRestoreFocus = treeFocusOwnedBeforeRender &&
+ !modalFocusManager.getActiveModal();
const focused = findTreeNodeByKey(saved.focusKey);
- if (focused && typeof focused.focus === "function") {
+ if (canRestoreFocus && focused && typeof focused.focus === "function") {
focused.focus({ preventScroll: true });
}
+ if (canRestoreFocus && saved.controlFocus && saved.controlFocus.id) {
+ const control = document.getElementById(saved.controlFocus.id);
+ if (control && categoryTree.contains(control) && control.disabled !== true &&
+ typeof control.focus === "function") {
+ control.focus({ preventScroll: true });
+ if (Number.isInteger(saved.controlFocus.selectionStart) &&
+ Number.isInteger(saved.controlFocus.selectionEnd) &&
+ typeof control.setSelectionRange === "function") {
+ try {
+ control.setSelectionRange(
+ saved.controlFocus.selectionStart,
+ saved.controlFocus.selectionEnd);
+ } catch (_) {
+ // Non-text controls can expose selection properties without accepting a range.
+ }
+ }
+ }
+ }
if (Number.isFinite(saved.scrollTop)) categoryTree.scrollTop = saved.scrollTop;
}
function send(type, payload) {
- if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} });
+ if (!webview || uiBusy) return false;
+ try {
+ webview.postMessage({ type: type, payload: payload || {} });
+ return true;
+ } catch (_) {
+ return false;
+ }
}
function isOperatorMutationLocked(state) {
@@ -491,11 +636,94 @@
function pointerPosition(event) {
const bounds = cutList.getBoundingClientRect();
return {
- x: Math.max(0, Math.round(event.clientX - bounds.left)),
- y: Math.max(0, Math.round(event.clientY - bounds.top))
+ x: event.clientX - bounds.left,
+ y: event.clientY - bounds.top
};
}
+ function pointInsideElement(element, event) {
+ if (!element || typeof element.getBoundingClientRect !== "function") return false;
+ const bounds = element.getBoundingClientRect();
+ return event.clientX >= bounds.left && event.clientX < bounds.right &&
+ event.clientY >= bounds.top && event.clientY < bounds.bottom;
+ }
+
+ function clearCutDragGesture(releaseCapture) {
+ const gesture = cutDragGesture;
+ if (!gesture) return;
+ cutDragGesture = null;
+ gesture.element.classList.remove("dragging");
+ playlistDropZone.classList.remove("cut-drag-over");
+ if (releaseCapture !== false &&
+ typeof gesture.element.hasPointerCapture === "function" &&
+ gesture.element.hasPointerCapture(gesture.pointerId) &&
+ typeof gesture.element.releasePointerCapture === "function") {
+ try {
+ gesture.element.releasePointerCapture(gesture.pointerId);
+ } catch (_) {
+ // The browser may release capture between the key event and cleanup.
+ }
+ }
+ }
+
+ function beginCutDragGesture(element, event, position) {
+ clearCutDragGesture();
+ if (event.button !== 0) return;
+ cutDragGesture = {
+ element: element,
+ pointerId: event.pointerId,
+ down: position,
+ rectangle: window.LegacyCutDrag.createDragRectangle(position, cutDragSize),
+ started: false
+ };
+ if (typeof element.setPointerCapture === "function") {
+ try {
+ element.setPointerCapture(event.pointerId);
+ } catch (_) {
+ // Pointer capture can fail if the WebView has already released it.
+ }
+ }
+ }
+
+ function onCutPointerMove(event) {
+ const gesture = cutDragGesture;
+ if (!gesture || gesture.element !== event.currentTarget ||
+ gesture.pointerId !== event.pointerId) return;
+ if ((event.buttons & 1) !== 1) {
+ clearCutDragGesture();
+ return;
+ }
+ if (!gesture.started &&
+ window.LegacyCutDrag.shouldStart(gesture.rectangle, pointerPosition(event))) {
+ gesture.started = true;
+ gesture.element.classList.add("dragging");
+ }
+ if (gesture.started) {
+ event.preventDefault();
+ playlistDropZone.classList.toggle(
+ "cut-drag-over",
+ !playlistMutationLocked && pointInsideElement(playlistDropZone, event));
+ }
+ }
+
+ function onCutPointerUp(event) {
+ const gesture = cutDragGesture;
+ if (!gesture || gesture.element !== event.currentTarget ||
+ gesture.pointerId !== event.pointerId) return;
+ const shouldDrop = gesture.started && !playlistMutationLocked &&
+ pointInsideElement(playlistDropZone, event);
+ clearCutDragGesture();
+ if (shouldDrop) send("drop-selected-cuts", {});
+ }
+
+ function onCutPointerCancel(event) {
+ const gesture = cutDragGesture;
+ if (gesture && gesture.element === event.currentTarget &&
+ gesture.pointerId === event.pointerId) {
+ clearCutDragGesture(event.type !== "lostpointercapture");
+ }
+ }
+
function legacyCutKey(key) {
return {
ArrowUp: "arrow-up",
@@ -533,7 +761,7 @@
element.setAttribute("role", "option");
element.dataset.physicalIndex = String(row.physicalIndex);
element.tabIndex = -1;
- element.draggable = true;
+ element.draggable = false;
const number = document.createElement("span");
number.className = "cut-number";
@@ -551,11 +779,18 @@
physicalIndex: row.physicalIndex,
control: event.ctrlKey,
shift: event.shiftKey,
- x: position.x,
- y: position.y,
+ // C# retains integer coordinates only for the native double-click
+ // equality contract. Drag geometry keeps the raw fractional CSS point.
+ x: Math.round(position.x),
+ y: Math.round(position.y),
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
});
+ beginCutDragGesture(element, event, position);
});
+ element.addEventListener("pointermove", onCutPointerMove);
+ element.addEventListener("pointerup", onCutPointerUp);
+ element.addEventListener("pointercancel", onCutPointerCancel);
+ element.addEventListener("lostpointercapture", onCutPointerCancel);
element.addEventListener("keydown", function (event) {
const key = legacyCutKey(event.key);
if (!key) return;
@@ -572,14 +807,6 @@
shift: event.shiftKey
});
});
- element.addEventListener("dragstart", function (event) {
- if (playlistMutationLocked || !event.dataTransfer) {
- event.preventDefault();
- return;
- }
- event.dataTransfer.effectAllowed = "move";
- event.dataTransfer.setData("text/plain", selectedCutsDragToken);
- });
return element;
}
@@ -592,6 +819,10 @@
// renderCuts precedes renderPlaylist, so publish the same lock before a
// cut row can initiate an append drag for this state revision.
playlistMutationLocked = cutDragLocked;
+ if (!cutSelectionEnabled || cutDragLocked ||
+ (cutDragGesture && !cutList.contains(cutDragGesture.element))) {
+ clearCutDragGesture();
+ }
state.cutRows.forEach(function (row, index) {
let element = cutList.children.item(index);
if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) {
@@ -605,18 +836,24 @@
element.className = "cut-row" +
(row.isSelected ? " selected" : "") +
- (row.isSeparator ? " separator" : "");
+ (row.isSeparator ? " separator" : "") +
+ (cutDragGesture && cutDragGesture.element === element && cutDragGesture.started
+ ? " dragging" : "");
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
element.tabIndex = row.isFocused === true || (focusedCutIndex < 0 && index === 0)
? 0
: -1;
- element.draggable = !cutDragLocked;
+ element.draggable = false;
+ element.dataset.cutDragEnabled = cutSelectionEnabled ? "true" : "false";
});
while (cutList.children.length > state.cutRows.length) {
cutList.lastElementChild.remove();
}
+ if (cutDragGesture && !cutList.contains(cutDragGesture.element)) {
+ clearCutDragGesture();
+ }
}
cutList.addEventListener("pointerdown", function (event) {
@@ -1772,6 +2009,7 @@
return;
}
+ if (manualModal.hidden) rememberModalOpener(manualDialog);
manualModal.hidden = false;
manualTitle.textContent = manual.label + " · GraphE 수동 입력";
const detailKey = manual.screen + "|" + (manual.selectedRowId || "new") + "|" +
@@ -1914,6 +2152,7 @@
return;
}
+ if (manualListModal.hidden) rememberModalOpener(manualListDialog);
manualListModal.hidden = false;
const busy = state.isBusy === true;
if (manual.screen !== "vi") {
@@ -2144,24 +2383,210 @@
}
function openNamedPlaylist(mode) {
+ if (operatorMutationLocked) return false;
+ namedPlaylistSelection.cancel();
namedPlaylistMode = mode;
namedPlaylistTitle.textContent = mode === "save"
? "DB 재생목록 저장" : "DB 재생목록 불러오기";
namedPlaylistConfirmButton.textContent = mode === "save" ? "현재 목록 저장" : "불러오기";
namedPlaylistNewTitle.value = "";
+ if (namedPlaylistModal.hidden) rememberModalOpener(namedPlaylistDialog);
namedPlaylistModal.hidden = false;
if (namedPlaylistState) {
renderNamedPlaylist({ namedPlaylist: namedPlaylistState, isBusy: uiBusy });
}
- send("refresh-named-playlists", {});
+ syncModalFocus();
+ beginNamedPlaylistBusyCycle("refresh-named-playlists", {});
+ return true;
}
function closeNamedPlaylist() {
- clearDelayedClick(namedPlaylistResultClickTimer);
- namedPlaylistResultClickTimer = null;
- pendingNamedPlaylistCommand = null;
+ if (isNamedPlaylistModalLocked() || namedPlaylistSelection.isPending()) return false;
namedPlaylistMode = null;
namedPlaylistModal.hidden = true;
+ syncModalFocus();
+ return true;
+ }
+
+ function isNamedPlaylistModalLocked() {
+ return uiBusy || pendingNamedPlaylistCommand !== null ||
+ namedPlaylistConfirmation !== null ||
+ !!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
+ }
+
+ function isNamedPlaylistActionLocked() {
+ return isNamedPlaylistModalLocked() || operatorMutationLocked ||
+ namedPlaylistSelection.isPending();
+ }
+
+ function isNamedPlaylistDefinitionLocked() {
+ return operatorMutationLocked || pendingNamedPlaylistCommand !== null ||
+ namedPlaylistConfirmation !== null ||
+ !!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true) ||
+ (uiBusy && !namedPlaylistSelection.isPending());
+ }
+
+ function markNamedPlaylistVisualSelection(definitionId) {
+ namedPlaylistDefinitions
+ .querySelectorAll("button[data-named-playlist-definition-id]")
+ .forEach(function (button) {
+ const selected = button.dataset.namedPlaylistDefinitionId === definitionId;
+ button.setAttribute("aria-selected", selected ? "true" : "false");
+ button.classList.toggle("selected", selected);
+ });
+ }
+
+ function lockNamedPlaylistSelectionActions() {
+ namedPlaylistClose.disabled = true;
+ namedPlaylistCancelButton.disabled = true;
+ namedPlaylistNewTitle.disabled = true;
+ namedPlaylistCreateButton.disabled = true;
+ namedPlaylistDeleteButton.disabled = true;
+ namedPlaylistConfirmButton.disabled = true;
+ }
+
+ function beginNamedPlaylistSelection(definitionId) {
+ if (namedPlaylistSelection.isPending()) {
+ const pending = namedPlaylistSelection.begin(
+ definitionId,
+ namedPlaylistState && namedPlaylistState.selectedDefinitionId);
+ if (pending.accepted) markNamedPlaylistVisualSelection(definitionId);
+ return pending.accepted;
+ }
+ if (isNamedPlaylistModalLocked() || operatorMutationLocked) return false;
+
+ const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++);
+ const started = namedPlaylistSelection.begin(
+ definitionId,
+ namedPlaylistState && namedPlaylistState.selectedDefinitionId,
+ requestId,
+ lastCommandSequence + 1);
+ if (!started.accepted) return false;
+ markNamedPlaylistVisualSelection(definitionId);
+ if (!started.shouldSend) return true;
+
+ lockNamedPlaylistSelectionActions();
+ if (!send("select-named-playlist", {
+ definitionId: definitionId,
+ requestId: started.requestId
+ })) {
+ namedPlaylistSelection.cancel();
+ restoreNamedPlaylistAfterConfirmation();
+ return false;
+ }
+ return true;
+ }
+
+ function runNamedPlaylistDoubleClick(definitionId, mode) {
+ if (mode === "save") {
+ return openNamedPlaylistConfirmation({
+ kind: "save",
+ definitionId: definitionId,
+ message: "저장하겠습니까?",
+ showSavedAlert: false,
+ closeOnNo: true
+ });
+ }
+ return beginNamedPlaylistCommand("load-named-playlist-by-id", definitionId, false);
+ }
+
+ function lockNamedPlaylistControls() {
+ namedPlaylistModal.querySelectorAll("button, input").forEach(function (control) {
+ control.disabled = true;
+ });
+ }
+
+ function beginNamedPlaylistBusyCycle(type, payload) {
+ if (isNamedPlaylistActionLocked()) return false;
+ const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++);
+ pendingNamedPlaylistCommand = {
+ command: type,
+ targetId: requestId,
+ minimumSequence: lastCommandSequence + 1,
+ closeOnSuccess: false,
+ showSavedAlert: false
+ };
+ const correlatedPayload = Object.assign({}, payload || {}, { requestId: requestId });
+ if (!send(type, correlatedPayload)) {
+ pendingNamedPlaylistCommand = null;
+ return false;
+ }
+ lockNamedPlaylistControls();
+ return true;
+ }
+
+ function beginNamedPlaylistCommand(command, definitionId, showSavedAlert) {
+ if (isNamedPlaylistActionLocked() || !definitionId) return false;
+ pendingNamedPlaylistCommand = {
+ command: command,
+ targetId: definitionId,
+ definitionId: definitionId,
+ minimumSequence: lastCommandSequence + 1,
+ closeOnSuccess: true,
+ showSavedAlert: showSavedAlert === true
+ };
+ if (!send(command, { definitionId: definitionId })) {
+ pendingNamedPlaylistCommand = null;
+ return false;
+ }
+ lockNamedPlaylistControls();
+ return true;
+ }
+
+ function isMatchingNamedPlaylistReceipt(busy, pending, commandResult) {
+ const exact = !!pending && !!commandResult &&
+ Number.isSafeInteger(commandResult.sequence) &&
+ commandResult.sequence >= pending.minimumSequence &&
+ commandResult.command === pending.command &&
+ commandResult.targetId === pending.targetId;
+ return exact && (commandResult.succeeded === false ||
+ (busy !== true && commandResult.succeeded === true));
+ }
+
+ function openNamedPlaylistConfirmation(options) {
+ if (!options || isNamedPlaylistActionLocked()) return false;
+ namedPlaylistConfirmation = options;
+ namedPlaylistConfirmationMessage.textContent = options.message;
+ if (namedPlaylistConfirmationModal.hidden) {
+ rememberModalOpener(namedPlaylistConfirmationDialog);
+ }
+ namedPlaylistConfirmationModal.hidden = false;
+ lockNamedPlaylistControls();
+ syncModalFocus();
+ return true;
+ }
+
+ function restoreNamedPlaylistAfterConfirmation() {
+ if (!namedPlaylistState || namedPlaylistModal.hidden) return;
+ renderNamedPlaylist({
+ namedPlaylist: namedPlaylistState,
+ isBusy: uiBusy,
+ commandResult: null,
+ statusKind: null
+ });
+ }
+
+ function resolveNamedPlaylistConfirmation(confirmed) {
+ const confirmation = namedPlaylistConfirmation;
+ if (!confirmation) return false;
+ namedPlaylistConfirmation = null;
+ namedPlaylistConfirmationModal.hidden = true;
+ syncModalFocus();
+
+ if (!confirmed) {
+ if (confirmation.closeOnNo === true) closeNamedPlaylist();
+ else restoreNamedPlaylistAfterConfirmation();
+ return true;
+ }
+
+ const started = confirmation.kind === "delete"
+ ? beginNamedPlaylistBusyCycle("delete-selected-named-playlist", {})
+ : beginNamedPlaylistCommand(
+ "save-current-named-playlist-to",
+ confirmation.definitionId,
+ confirmation.showSavedAlert === true);
+ if (!started) restoreNamedPlaylistAfterConfirmation();
+ return started;
}
function renderNamedPlaylist(state) {
@@ -2169,32 +2594,49 @@
namedPlaylistState = named || null;
const busy = state.isBusy === true;
const commandResult = state.commandResult;
+ const selectionResult = namedPlaylistSelection.reconcile(
+ commandResult,
+ named && named.selectedDefinitionId);
+ const deferredSelectionAction = selectionResult.deferredAction;
if (commandResult && Number.isSafeInteger(commandResult.sequence)) {
lastCommandSequence = Math.max(lastCommandSequence, commandResult.sequence);
}
- if (!busy && pendingNamedPlaylistCommand && commandResult &&
- commandResult.sequence >= pendingNamedPlaylistCommand.minimumSequence &&
- commandResult.command === pendingNamedPlaylistCommand.command &&
- commandResult.targetId === pendingNamedPlaylistCommand.definitionId) {
+ if (isMatchingNamedPlaylistReceipt(
+ busy,
+ pendingNamedPlaylistCommand,
+ commandResult
+ )) {
+ const completed = pendingNamedPlaylistCommand;
const succeeded = commandResult.succeeded === true;
pendingNamedPlaylistCommand = null;
- if (succeeded) {
+ if (succeeded && completed.closeOnSuccess) {
+ if (completed.showSavedAlert) window.alert("저장되었습니다");
closeNamedPlaylist();
}
}
- dbLoadButton.disabled = busy || !named;
- dbSaveButton.disabled = busy || !named || named.canMutate !== true;
+ dbLoadButton.disabled = operatorMutationLocked || busy || !named;
+ dbSaveButton.disabled = operatorMutationLocked || busy || !named ||
+ named.canMutate !== true;
if (!named || namedPlaylistModal.hidden) return;
+ const modalLocked = isNamedPlaylistActionLocked();
+ const definitionLocked = isNamedPlaylistDefinitionLocked();
+ const existingDefinitions = new Map();
+ namedPlaylistDefinitions
+ .querySelectorAll("button[data-named-playlist-definition-id]")
+ .forEach(function (button) {
+ existingDefinitions.set(button.dataset.namedPlaylistDefinitionId, button);
+ });
const fragment = document.createDocumentFragment();
named.definitions.forEach(function (definition) {
- const button = document.createElement("button");
+ const button = existingDefinitions.get(definition.definitionId) ||
+ document.createElement("button");
button.type = "button";
button.dataset.namedPlaylistDefinitionId = definition.definitionId;
button.setAttribute("role", "option");
button.setAttribute("aria-selected", definition.isSelected ? "true" : "false");
button.className = definition.isSelected ? "selected" : "";
- button.disabled = busy || named.isWriteInProgress === true;
+ button.disabled = definitionLocked;
button.textContent = definition.title;
fragment.appendChild(button);
});
@@ -2212,11 +2654,24 @@
namedPlaylistStatus.classList.toggle("error", named.isWriteQuarantined === true ||
named.statusKind === "error");
- namedPlaylistCreateButton.disabled = busy || named.canCreate !== true ||
+ const closeLocked = isNamedPlaylistModalLocked() ||
+ namedPlaylistSelection.isPending();
+ namedPlaylistClose.disabled = closeLocked;
+ namedPlaylistCancelButton.disabled = closeLocked;
+ namedPlaylistNewTitle.disabled = modalLocked;
+ namedPlaylistCreateButton.disabled = modalLocked || named.canCreate !== true ||
!isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value);
- namedPlaylistDeleteButton.disabled = busy || named.canDelete !== true;
- namedPlaylistConfirmButton.disabled = busy ||
+ namedPlaylistDeleteButton.disabled = modalLocked || named.canDelete !== true;
+ namedPlaylistConfirmButton.disabled = modalLocked ||
(namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true);
+ namedPlaylistConfirmationYes.disabled = operatorMutationLocked || uiBusy ||
+ pendingNamedPlaylistCommand !== null ||
+ !!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
+ if (deferredSelectionAction) {
+ runNamedPlaylistDoubleClick(
+ deferredSelectionAction.definitionId,
+ deferredSelectionAction.mode);
+ }
}
function clearOperatorCatalogDragVisuals() {
@@ -2329,6 +2784,7 @@
clearOperatorCatalogDragVisuals();
}
+ if (operatorCatalogModal.hidden) rememberModalOpener(operatorCatalogDialog);
operatorCatalogModal.hidden = false;
const isTheme = catalog.entity === "theme";
const editing = catalog.mode === "create" || catalog.mode === "edit";
@@ -2512,22 +2968,46 @@
function renderDialog(state) {
if (state.dialog && state.dialog.message) {
dialogMessage.textContent = state.dialog.message;
+ if (dialog.hidden) rememberModalOpener(legacyAlertDialog);
dialog.hidden = false;
- dialogOk.focus();
} else {
dialog.hidden = true;
}
}
+ function updateCutDragSize(state) {
+ const metrics = state && state.interactionMetrics;
+ if (!metrics || metrics.coordinateSpace !== "host-dip" ||
+ !Number.isInteger(metrics.cutDragWidthDips) ||
+ !Number.isInteger(metrics.cutDragHeightDips) ||
+ metrics.cutDragWidthDips <= 0 || metrics.cutDragHeightDips <= 0 ||
+ typeof metrics.hostRasterizationScale !== "number") return;
+ const nextDragSize = window.LegacyCutDrag.toCssDragSize({
+ width: metrics.cutDragWidthDips,
+ height: metrics.cutDragHeightDips
+ }, metrics.hostRasterizationScale, window.devicePixelRatio);
+ cutDragSize = nextDragSize;
+ if (cutDragGesture && !cutDragGesture.started) {
+ // The cut-pointer-down reply is also a native state boundary. If a
+ // Windows preference change raced the previous rendered state, update
+ // the still-pending gesture before its next pointermove decision.
+ cutDragGesture.rectangle = window.LegacyCutDrag.createDragRectangle(
+ cutDragGesture.down,
+ nextDragSize);
+ }
+ }
+
function render(state) {
const isBusy = state.isBusy === true;
const treeTabId = beginTreeRender(state);
uiBusy = isBusy;
+ operatorMutationLocked = isOperatorMutationLocked(state);
document.body.classList.toggle("busy", isBusy);
document.body.setAttribute("aria-busy", isBusy ? "true" : "false");
searchInput.readOnly = isBusy;
searchButton.setAttribute("aria-disabled", isBusy ? "true" : "false");
if (document.activeElement !== searchInput) searchInput.value = state.searchText || "";
+ updateCutDragSize(state);
renderStockResults(state);
renderCuts(state);
renderTabs(state);
@@ -2550,6 +3030,7 @@
renderPlaylist(state);
renderNamedPlaylist(state);
renderDialog(state);
+ syncModalFocus();
status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || "");
}
@@ -2576,36 +3057,25 @@
const definition = event.target.closest("button[data-named-playlist-definition-id]");
if (!definition || definition.disabled) return;
event.preventDefault();
- clearDelayedClick(namedPlaylistResultClickTimer);
- namedPlaylistResultClickTimer = null;
const definitionId = definition.dataset.namedPlaylistDefinitionId;
- if (namedPlaylistMode === "save" &&
- !window.confirm("선택한 DB 재생목록에 현재 목록을 저장하시겠습니까?")) {
+ if (namedPlaylistSelection.isPending()) {
+ namedPlaylistSelection.defer(definitionId, {
+ definitionId: definitionId,
+ mode: namedPlaylistMode
+ });
return;
}
- const command = namedPlaylistMode === "save"
- ? "save-current-named-playlist-to" : "load-named-playlist-by-id";
- pendingNamedPlaylistCommand = {
- command: command,
- definitionId: definitionId,
- minimumSequence: lastCommandSequence + 1
- };
- send(command, { definitionId: definitionId });
+ runNamedPlaylistDoubleClick(definitionId, namedPlaylistMode);
});
namedPlaylistDefinitions.addEventListener("click", function (event) {
const definition = event.target.closest("button[data-named-playlist-definition-id]");
if (definition && !definition.disabled) {
- clearDelayedClick(namedPlaylistResultClickTimer);
- namedPlaylistResultClickTimer = window.setTimeout(function () {
- namedPlaylistResultClickTimer = null;
- send("select-named-playlist", {
- definitionId: definition.dataset.namedPlaylistDefinitionId
- });
- }, 250);
+ beginNamedPlaylistSelection(definition.dataset.namedPlaylistDefinitionId);
}
});
namedPlaylistNewTitle.addEventListener("input", function () {
- namedPlaylistCreateButton.disabled = uiBusy || !namedPlaylistState ||
+ namedPlaylistCreateButton.disabled = isNamedPlaylistActionLocked() ||
+ !namedPlaylistState ||
namedPlaylistState.canCreate !== true ||
!isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value);
});
@@ -2618,23 +3088,42 @@
namedPlaylistCreateButton.addEventListener("click", function () {
const title = namedPlaylistNewTitle.value;
if (isCanonicalNamedPlaylistTitle(title)) {
- send("create-named-playlist", { title: title });
+ beginNamedPlaylistBusyCycle("create-named-playlist", { title: title });
}
});
namedPlaylistDeleteButton.addEventListener("click", function () {
- const title = namedPlaylistState && namedPlaylistState.selectedTitle
- ? "'" + namedPlaylistState.selectedTitle + "'" : "선택한 재생목록";
- if (window.confirm(title + "을(를) DB에서 삭제하겠습니까?")) {
- send("delete-selected-named-playlist", {});
- }
+ openNamedPlaylistConfirmation({
+ kind: "delete",
+ message: "프로그램이 삭제됩니다. 계속 하시겠습니까?",
+ closeOnNo: false
+ });
});
namedPlaylistConfirmButton.addEventListener("click", function () {
+ const definitionId = namedPlaylistState && namedPlaylistState.selectedDefinitionId;
+ if (!definitionId) return;
if (namedPlaylistMode === "save") {
- send("save-current-named-playlist", {});
+ openNamedPlaylistConfirmation({
+ kind: "save",
+ definitionId: definitionId,
+ message: "저장하겠습니까?",
+ showSavedAlert: true,
+ closeOnNo: true
+ });
} else if (namedPlaylistMode === "load") {
- send("load-selected-named-playlist", {});
+ beginNamedPlaylistCommand(
+ "load-named-playlist-by-id",
+ definitionId,
+ false);
}
});
+ namedPlaylistConfirmationYes.addEventListener("click", function () {
+ if (!namedPlaylistConfirmationYes.disabled) {
+ resolveNamedPlaylistConfirmation(true);
+ }
+ });
+ namedPlaylistConfirmationNo.addEventListener("click", function () {
+ resolveNamedPlaylistConfirmation(false);
+ });
manualButtons.forEach(function (button) {
button.addEventListener("click", function () {
@@ -2962,31 +3451,6 @@
searchOperatorCatalog();
});
- function hasDragType(dataTransfer, expectedType) {
- return !!dataTransfer && Array.from(dataTransfer.types || [])
- .some(function (type) { return type === expectedType; });
- }
-
- function isSelectedCutsDrop(dataTransfer) {
- return hasDragType(dataTransfer, "text/plain") &&
- dataTransfer.getData("text/plain") === selectedCutsDragToken;
- }
-
- playlistDropZone.addEventListener("dragover", function (event) {
- // A playlist row also drops inside this ancestor. Only advertise the
- // drop zone for the cut-list payload; otherwise the bubbled row drop
- // would append the still-selected cut as an extra playlist entry.
- if (playlistMutationLocked ||
- !hasDragType(event.dataTransfer, "text/plain")) return;
- event.preventDefault();
- if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
- });
- playlistDropZone.addEventListener("drop", function (event) {
- if (playlistMutationLocked || !isSelectedCutsDrop(event.dataTransfer)) return;
- event.preventDefault();
- event.stopPropagation();
- send("drop-selected-cuts", {});
- });
playlistMoveUp.addEventListener("click", function () {
send("move-selected-playlist-rows", { direction: "up" });
});
@@ -3420,13 +3884,31 @@
operatorCatalogDragBlockedRowId = null;
});
document.addEventListener("keydown", function (event) {
- if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
- manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) {
+ const cutDragShortcut = event.key === "Escape" || event.key === "F2" ||
+ event.key === "F3" || event.key === "F8";
+ if (cutDragGesture && cutDragGesture.started && cutDragShortcut) {
+ // Native DoDragDrop runs its own modal loop. MainForm shortcuts cannot run
+ // until that loop ends; Escape cancels the drag without issuing TAKE OUT.
+ event.preventDefault();
+ if (event.key === "Escape") clearCutDragGesture();
+ } else if (event.key === "Escape" && modalFocusManager.getActiveModal()) {
+ // The legacy child forms did not assign a CancelButton and none handled
+ // Escape. ShowDialog isolated MainForm, so Escape cannot fall through to
+ // MainForm's TAKE OUT shortcut. The one-button MessageBox is the exception:
+ // its Escape is equivalent to acknowledging OK.
+ event.preventDefault();
+ if (!event.repeat && modalFocusManager.getActiveModal() === legacyAlertDialog) {
+ send("dismiss-dialog", {});
+ }
+ } else if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
+ manualListModal.hidden && namedPlaylistModal.hidden &&
+ namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden) {
// MainForm.KeyPreview invoked playlist deletion but did not suppress the key,
// so an editor that owns focus must still perform its normal text deletion.
if (!playlistMutationLocked) send("delete-selected-playlist-rows", {});
} else if (event.key === " " && !event.repeat && dialog.hidden && manualModal.hidden &&
- manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden &&
+ manualListModal.hidden && namedPlaylistModal.hidden &&
+ namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden &&
event.target.closest(".playlist-data-row")) {
event.preventDefault();
if (!playlistMutationLocked) {
@@ -3439,26 +3921,11 @@
}
} else if ((event.key === "Home" || event.key === "End") &&
!event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden &&
- namedPlaylistModal.hidden && operatorCatalogModal.hidden) {
+ namedPlaylistModal.hidden && namedPlaylistConfirmationModal.hidden &&
+ operatorCatalogModal.hidden) {
// MainForm handled its playlist/timer side effect and then returned the
// base key result, so a focused textbox/list control still receives Home/End.
selectPlaylistBoundary(event.key === "Home" ? "first" : "last");
- } else if (event.key === "Escape" && !event.repeat && !namedPlaylistModal.hidden) {
- event.preventDefault();
- closeNamedPlaylist();
- } else if (event.key === "Escape" && !event.repeat && !manualModal.hidden) {
- event.preventDefault();
- clearDelayedClick(manualResultClickTimer);
- manualResultClickTimer = null;
- send("close-manual-financial", {});
- } else if (event.key === "Escape" && !event.repeat && !manualListModal.hidden) {
- event.preventDefault();
- send("close-manual-list", {});
- } else if (event.key === "Escape" && !event.repeat && !operatorCatalogModal.hidden) {
- event.preventDefault();
- clearDelayedClick(operatorCatalogStockClickTimer);
- operatorCatalogStockClickTimer = null;
- send("close-operator-catalog", {});
}
});
dialogOk.addEventListener("click", function () {
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html
index f33dcd9..8c7f31c 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html
@@ -69,8 +69,10 @@
-
-