using System.Windows; using System.Windows.Controls; using SheetMe.Designer.Services; using SheetMe.Designer.ViewModels; namespace SheetMe.Designer.Views; /// /// 미리보기 창 — 편집 크롬 없는 페이지 렌더(인쇄와 동일 비주얼) + 줌/인쇄. /// /// 창은 하나만 뜬다. 전에는 누를 때마다 새 창이 생겼다. 모덜리스라 여러 장이 겹쳐 쌓이고, /// 각각이 서로 다른 시점의 서식을 들고 있었다. 어느 것이 지금 것인지 화면으로 구분할 수 없다. /// 환자 데이터가 들어가면 그건 개인정보 사고가 된다 — 환자를 바꿔도 옛 창이 옛 환자를 계속 띄운다. /// /// 내용은 따라간다. 전에는 생성자에서 한 번만 그려서, 컨트롤을 추가·삭제하거나 /// 숨김·인쇄출력을 토글해도 창은 옛 그림을 계속 보여 줬다 — /// 저장 전에 결과를 확인하려고 여는 창이 구조적으로 거짓말을 하고 있었다. /// 페이지·컨트롤 추가/삭제는 즉시, 그 밖의 변경은 창을 누르는 순간(Activated) 다시 그린다. /// 즉시 갱신이 안 되는 변경이 남아 있으므로 새로 고침 버튼도 함께 둔다. /// public partial class PreviewWindow : Window { #region Member Fields /// 지금 떠 있는 창 — 두 번째를 만들지 않는다 private static PreviewWindow? open; private DesignerViewModel designer; private double zoom = 1.0; /// /// 시각·로그인 사용자 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다. /// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다. /// private readonly SessionTagResolver session = new(); /// /// 지금 쓰는 해석기. 환자를 고르면 가 앞에 붙고, /// 해제하면 다시 뿐이다 — 환자를 해제했는데 옛 값이 남으면 /// 화면이 실제와 다른 것을 보여 준다. /// private SheetMe.Core.Catalog.ITagValueResolver tags; /// /// MDataTable 실행기 — DataTableField 배선의 값을 낸다. /// 환자를 바꾸면 새로 만든다(캐시에 옛 환자의 표가 남으면 안 된다). /// private MDataTableRunner? fields; /// 지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다 private SheetMe.Data.Stores.PatientContext? patient; #endregion #region Constructors /// /// 진단(--dialog-shots)만 직접 부른다 — 앱 코드는 를 쓸 것. /// 여기로 만들면 단일 인스턴스 등록을 건너뛰므로 두 장이 생긴다. /// internal PreviewWindow(DesignerViewModel designer) { this.designer = designer; tags = session; InitializeComponent(); Activated += (_, _) => BuildPages(); // 환자는 앱 전체가 하나를 공유한다 — 제목줄 아이콘에서 바꿔도 이 창이 따라와야 한다 PatientSession.Changed += OnPatientSessionChanged; Closed += (_, _) => { Unsubscribe(); PatientSession.Changed -= OnPatientSessionChanged; if (ReferenceEquals(open, this)) { open = null; } }; Subscribe(); // 이미 다른 화면에서 고른 환자가 있으면 그것을 그대로 물려받는다 — // 창을 열 때마다 초기화하면 "앱 전체 적용"이 거짓이 된다. // 환자가 없어도 러너는 만든다(데이터소스 개수를 알아야 버튼 상태가 맞다). UsePatient(PatientSession.Current, PatientSession.Label); } #endregion #region Methods /// /// 미리보기를 보여 준다 — 이미 떠 있으면 그 창을 지금 문서로 바꿔 앞으로 가져온다. /// 새 창을 하나 더 만들면 어느 것이 지금 것인지 알 수 없다. /// public static void ShowFor(DesignerViewModel designer, Window? owner) { if (open is null) { open = new PreviewWindow(designer) { Owner = owner }; open.Show(); return; } open.Retarget(designer); if (open.WindowState == WindowState.Minimized) { open.WindowState = WindowState.Normal; } open.Activate(); } /// 다른 문서로 갈아탄다 — 탭을 바꾸고 미리보기를 다시 누른 경우 private void Retarget(DesignerViewModel next) { if (ReferenceEquals(designer, next)) { BuildPages(); return; } Unsubscribe(); designer = next; Subscribe(); // 서식이 바뀌면 데이터소스도 바뀐다 — 러너를 다시 만들지 않으면 옛 서식의 쿼리로 // 새 서식의 배선을 채우려 든다. 환자는 그대로 유지한다(같은 환자로 다른 서식을 보는 일이 흔하다). UsePatient(patient, PatientText.Text); } /// 페이지·컨트롤 추가/삭제는 즉시 따라간다 private void Subscribe() { designer.Pages.CollectionChanged += OnStructureChanged; foreach (var page in designer.Pages) { page.Controls.CollectionChanged += OnStructureChanged; } } private void Unsubscribe() { designer.Pages.CollectionChanged -= OnStructureChanged; foreach (var page in designer.Pages) { page.Controls.CollectionChanged -= OnStructureChanged; } } private void OnStructureChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { // 페이지가 늘거나 줄면 그 페이지의 컨트롤 구독도 다시 걸어야 한다 if (ReferenceEquals(sender, designer.Pages)) { foreach (var page in designer.Pages) { page.Controls.CollectionChanged -= OnStructureChanged; page.Controls.CollectionChanged += OnStructureChanged; } } BuildPages(); } private void BuildPages() { // XAML 파싱 중에도 불린다 — 토글의 IsChecked="True" 가 파스 시점에 Checked 를 발생시키는데 // 그때 PagesHost 는 아직 만들어지지 않았다(토글이 트리에서 앞에 있다). // 이 가드가 없으면 InitializeComponent 안에서 NullReferenceException 이 난다. if (PagesHost is null) { return; } PagesHost.Children.Clear(); foreach (var page in designer.Pages) { var frame = new Border { Background = page.PaperBrush, BorderBrush = System.Windows.Media.Brushes.LightGray, BorderThickness = new Thickness(1), Margin = new Thickness(0, 0, 0, 20), Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields), Width = page.WidthDip, Height = page.HeightDip, }; PagesHost.Children.Add(frame); } Title = designer.Document.Title.Length > 0 ? $"미리보기 — {designer.Document.Title}" : "미리보기"; } private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages(); /// /// 실제 환자를 골라 태그에 값을 넣는다. /// /// 권한 문·감사 기록·문맥 읽기는 모두 안에 있다 — /// 여기서 다시 확인하면 두 곳이 어긋날 수 있다. /// private void OnPickPatient(object sender, RoutedEventArgs e) { var picked = PatientPickerDialogView.Pick(this, out var label); if (picked is null) { return; } // 세션에만 쓴다 — 그러면 Changed 가 이 창을 포함한 모든 화면을 갱신한다. // 여기서 직접 UsePatient 도 부르면 두 번 그리고, 더 나쁘게는 // 세션과 창이 어긋나는 경로가 하나 더 생긴다. PatientSession.Set(picked, label); } /// 환자를 뗀다 — 종이에서 값이 사라지고 사유로 돌아가는 것까지 확인해야 한다 private void OnClearPatient(object sender, RoutedEventArgs e) => PatientSession.Set(null, string.Empty); private void OnPatientSessionChanged(object? sender, EventArgs e) => UsePatient(PatientSession.Current, PatientSession.Label); /// /// 환자를 붙이거나 뗀다 — 해석기 교체·표시·다시 그리기를 한 군데에 모은다. /// /// 모달과 분리하는 이유: 고르는 일(권한·감사·문맥 읽기)과 붙이는 일(해석기 교체 → 종이)은 /// 다른 일이고, 붙이는 쪽만 진단이 확인할 수 있어야 한다. 창을 띄우는 코드에 묻어 두면 /// "환자를 골랐는데 종이가 그대로다"를 자동으로 잡을 방법이 없다 — 그게 이 단계에서 가장 무서운 고장이다. /// internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label) { patient = picked; tags = picked is null ? session : new PatientTagResolver(picked, session); // 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. fields = new MDataTableRunner(designer.Document, picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod)); PatientText.Text = picked is null ? "환자 없음" : label; PatientClearButton.IsEnabled = picked is not null; DataSourceButton.IsEnabled = fields.Names.Count > 0; BuildPages(); } /// /// 데이터소스 조회 결과를 펼쳐 본다 — 종이에 값이 안 찍혔을 때 인지는 여기서만 알 수 있다. /// /// 종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]"처럼 한 줄만 나온다. /// 그것으로는 쿼리가 틀렸는지, 치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 못 가른다. /// 치환을 마친 SQL 원문을 보여 주는 것이 이 창의 핵심이다. /// private void OnShowDataSources(object sender, RoutedEventArgs e) { if (fields is null) { return; } DataSourceResultDialogView.ShowFor(fields, this); } /// 보기 전환 — 인쇄 기준 / 화면 그대로 private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages(); private void OnPrint(object sender, RoutedEventArgs e) { try { PrintService.Print(designer, designer.Document.Title.Length > 0 ? designer.Document.Title : designer.Document.FormId); } catch (Exception ex) { DialogService.Notify(DialogKind.Error, "오류", "인쇄 중 오류가 발생했습니다.", ex.Message, this); } } private void OnZoomIn(object sender, RoutedEventArgs e) => ApplyZoom(zoom * 1.15); private void OnZoomOut(object sender, RoutedEventArgs e) => ApplyZoom(zoom / 1.15); private void ApplyZoom(double value) { zoom = Math.Clamp(value, 0.25, 3.0); ZoomTransform.ScaleX = zoom; ZoomTransform.ScaleY = zoom; ZoomText.Text = $"{zoom * 100:0}%"; } #endregion }