using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using SheetMe.Designer.ViewModels; using SheetMe.Designer.ViewModels.Controls; namespace SheetMe.Designer.Views; /// /// 디자인 캔버스 뷰 — 코드비하인드 허용 범위: ①줌 휠 처리(커서 중심 스크롤 보정) ②캔버스 포커스 ③인라인 텍스트 에디터. /// 문서 변경 로직은 두지 않는다(커밋은 DesignerViewModel.CommitInlineText 경유). /// public partial class DesignerCanvasView : UserControl { #region Member Fields private DesignerViewModel? subscribedDesigner; private TextBox? inlineEditor; private ControlViewModel? editingTarget; // 손(팬) 도구 — 플로팅 바 토글 또는 Space 누르는 동안 private bool isPanning; private bool spacePanHeld; private Point panStartPoint; private double panStartHorizontal; private double panStartVertical; #endregion #region Constructors public DesignerCanvasView() { InitializeComponent(); DataContextChanged += OnDataContextChanged; } #endregion #region Methods - 인라인 에디터 private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) { if (subscribedDesigner is not null) { subscribedDesigner.InlineEditRequested -= ShowInlineEditor; subscribedDesigner.ScrollToRequested -= ScrollToControl; subscribedDesigner.ScrollToPageRequested -= ScrollToPage; } CloseInlineEditor(commit: false); subscribedDesigner = e.NewValue as DesignerViewModel; if (subscribedDesigner is not null) { subscribedDesigner.InlineEditRequested += ShowInlineEditor; subscribedDesigner.ScrollToRequested += ScrollToControl; subscribedDesigner.ScrollToPageRequested += ScrollToPage; subscribedDesigner.ReportViewport(Scroll.ViewportWidth, Scroll.ViewportHeight); } } /// /// 페이지 머리가 화면 위쪽에 오도록 스크롤 — 레이어 아웃라인의 페이지 헤더 클릭 경로. /// 캔버스가 전 페이지를 하나의 월드에 세로로 쌓아 그리므로 페이지 오프셋만 알면 된다. /// private void ScrollToPage(PageViewModel page) { if (subscribedDesigner is null) { return; } Dispatcher.BeginInvoke(new Action(() => { const double CanvasPad = 24; // DesignerCanvasView.xaml 의 Border Padding const double HeadRoom = 8; // 페이지 윗선이 뷰포트 경계에 딱 붙지 않도록 Scroll.ScrollToVerticalOffset(page.OffsetY * subscribedDesigner.Zoom + CanvasPad - HeadRoom); }), System.Windows.Threading.DispatcherPriority.Loaded); } /// /// 컨트롤이 화면 가운데 오도록 스크롤 — 레이어 목록 더블클릭 경로. /// /// 월드 좌표 → 화면 좌표 변환은 World 그리드의 LayoutTransform(줌)과 감싸는 Border 의 /// Padding 24 를 모두 반영해야 한다. 이미 보이는 컨트롤은 건드리지 않는다 — /// 목록을 훑을 때마다 화면이 튀면 오히려 방향 감각을 잃는다. /// private void ScrollToControl(ControlViewModel target) { if (subscribedDesigner is null) { return; } // 페이지 전환·선택 변경이 레이아웃에 반영된 뒤 계산해야 오프셋이 맞는다 Dispatcher.BeginInvoke(new Action(() => { var bounds = subscribedDesigner.WorldBoundsOf(target); var zoom = subscribedDesigner.Zoom; const double CanvasPad = 24; // DesignerCanvasView.xaml 의 Border Padding var left = bounds.X * zoom + CanvasPad; var top = bounds.Y * zoom + CanvasPad; var width = bounds.Width * zoom; var height = bounds.Height * zoom; var visibleLeft = Scroll.HorizontalOffset; var visibleTop = Scroll.VerticalOffset; var visibleRight = visibleLeft + Scroll.ViewportWidth; var visibleBottom = visibleTop + Scroll.ViewportHeight; var fullyVisible = left >= visibleLeft && top >= visibleTop && left + width <= visibleRight && top + height <= visibleBottom; if (fullyVisible) { return; } Scroll.ScrollToHorizontalOffset(left + (width / 2) - (Scroll.ViewportWidth / 2)); Scroll.ScrollToVerticalOffset(top + (height / 2) - (Scroll.ViewportHeight / 2)); }), System.Windows.Threading.DispatcherPriority.Loaded); } /// 뷰포트 크기를 VM 에 전달 — 화면 맞춤(ZoomFit)이 시각 트리를 몰라도 되게 한다 private void OnScrollSizeChanged(object sender, SizeChangedEventArgs e) => subscribedDesigner?.ReportViewport(Scroll.ViewportWidth, Scroll.ViewportHeight); /// 대상 컨트롤 위에 편집기 표시 — 폰트/정렬 일치, 줌은 World LayoutTransform 이 처리 private void ShowInlineEditor(ControlViewModel target) { if (subscribedDesigner is null) { return; } CloseInlineEditor(commit: true); // 데이터소스(MDataTable)는 인라인 대신 전용 쿼리 편집기 창 if (target is DataTableViewModel dataTable) { // 《컨트롤명》 치환 후보 — 같은 서식의 컨트롤만 유효하다 var queryDialog = new QueryEditorWindow(dataTable.Id, dataTable.Model.Props.GetText("Query") ?? string.Empty, controlNames: subscribedDesigner.ControlNames()) { Owner = Window.GetWindow(this), }; if (queryDialog.ShowDialog() == true) { subscribedDesigner.CommitPropertyText(dataTable, "Query", queryDialog.QueryText); } return; } var bounds = subscribedDesigner.WorldBoundsOf(target); var multiline = target is LabelViewModel || (target is TextBoxViewModel textBox && textBox.Multiline); var editor = new TextBox { // 종이 위 텍스트와 픽셀 일치가 목적 — 앱 테마의 암시 TextBox 템플릿(입력칩 라운드/MinHeight) 차단 Style = new Style(typeof(TextBox)), Text = target.Text, FontFamily = target.FontFamily, FontSize = target.FontSize, FontWeight = target.FontWeight, FontStyle = target.FontStyle, Foreground = target.Foreground, Background = Brushes.White, BorderBrush = new SolidColorBrush(Color.FromRgb(0x1E, 0x7B, 0xE8)), BorderThickness = new Thickness(1), Padding = new Thickness(1, 0, 1, 0), MinWidth = Math.Max(40, bounds.Width), MinHeight = bounds.Height, MaxWidth = 640, AcceptsReturn = multiline, TextWrapping = multiline ? TextWrapping.Wrap : TextWrapping.NoWrap, VerticalContentAlignment = multiline ? VerticalAlignment.Top : VerticalAlignment.Center, ToolTip = multiline ? "Enter 확정 · Shift+Enter 줄바꿈 · Esc 취소" : "Enter 확정 · Esc 취소", }; Canvas.SetLeft(editor, bounds.X); Canvas.SetTop(editor, bounds.Y); editor.PreviewKeyDown += OnEditorKeyDown; editor.LostKeyboardFocus += OnEditorLostFocus; editingTarget = target; inlineEditor = editor; EditorLayer.Children.Add(editor); // 레이아웃 이후 포커스 + 전체 선택 Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, () => { editor.Focus(); editor.SelectAll(); }); } private void OnEditorKeyDown(object sender, KeyEventArgs e) { if (inlineEditor is null) { return; } if (e.Key == Key.Escape) { e.Handled = true; CloseInlineEditor(commit: false); Focus(); } else if (e.Key == Key.Enter && !Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) { // Enter=확정, Shift+Enter=줄바꿈(여러 줄 편집기) e.Handled = true; CloseInlineEditor(commit: true); Focus(); } } private void OnEditorLostFocus(object sender, KeyboardFocusChangedEventArgs e) => CloseInlineEditor(commit: true); private void CloseInlineEditor(bool commit) { if (inlineEditor is null || editingTarget is null) { return; } var editor = inlineEditor; var target = editingTarget; inlineEditor = null; editingTarget = null; editor.PreviewKeyDown -= OnEditorKeyDown; editor.LostKeyboardFocus -= OnEditorLostFocus; EditorLayer.Children.Remove(editor); if (commit) { subscribedDesigner?.CommitInlineText(target, editor.Text); } } #endregion #region Methods - 손(팬) 도구 /// 손 도구 활성 여부 — 플로팅 바 토글([200] 관행) 또는 Space 누르는 동안 private bool IsHandToolActive => spacePanHeld || (Application.Current.MainWindow?.DataContext as MainViewModel)?.IsHandTool == true; /// 팬 시작 — Preview 단계 선점으로 하위 선택/이동 로직(World 버블링) 차단 private void OnPanMouseDown(object sender, MouseButtonEventArgs e) { if (!IsHandToolActive) { return; } isPanning = true; panStartPoint = e.GetPosition(Scroll); panStartHorizontal = Scroll.HorizontalOffset; panStartVertical = Scroll.VerticalOffset; Scroll.CaptureMouse(); e.Handled = true; } private void OnPanMouseMove(object sender, MouseEventArgs e) { // 손 모드 커서 — 하위(World) 히트테스트 커서보다 우선하도록 ForceCursor if (IsHandToolActive) { Scroll.Cursor = Cursors.Hand; Scroll.ForceCursor = true; } else if (Scroll.ForceCursor) { Scroll.ForceCursor = false; Scroll.Cursor = null; } if (isPanning) { var position = e.GetPosition(Scroll); Scroll.ScrollToHorizontalOffset(panStartHorizontal - (position.X - panStartPoint.X)); Scroll.ScrollToVerticalOffset(panStartVertical - (position.Y - panStartPoint.Y)); e.Handled = true; } else if (IsHandToolActive) { e.Handled = true; // 손 모드 유휴 이동도 선점 — World 커서 갱신 억제 } } private void OnPanMouseUp(object sender, MouseButtonEventArgs e) { if (!isPanning) { return; } isPanning = false; Scroll.ReleaseMouseCapture(); e.Handled = true; } private void OnPanLostCapture(object sender, MouseEventArgs e) => isPanning = false; /// Space 누르는 동안 임시 손 도구 — 텍스트 입력 중에는 개입하지 않음 private void OnCanvasPreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Space && !e.IsRepeat && e.OriginalSource is not TextBox) { spacePanHeld = true; Scroll.Cursor = Cursors.Hand; Scroll.ForceCursor = true; e.Handled = true; // ScrollViewer 의 Space 페이지 스크롤 기본 동작 차단 } } private void OnCanvasPreviewKeyUp(object sender, KeyEventArgs e) { if (e.Key == Key.Space && spacePanHeld) { spacePanHeld = false; if (!IsHandToolActive) { Scroll.ForceCursor = false; Scroll.Cursor = null; } e.Handled = true; } } #endregion #region Methods - 줌 /// Ctrl+휠 줌 — 커서 논리 위치를 유지하도록 스크롤 오프셋 보정 private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e) { if (Keyboard.Modifiers != ModifierKeys.Control) { return; } if (DataContext is not DesignerViewModel designer) { return; } e.Handled = true; var oldZoom = designer.Zoom; if (e.Delta > 0) { designer.ZoomIn(); } else { designer.ZoomOut(); } var newZoom = designer.Zoom; if (Math.Abs(newZoom - oldZoom) < 0.0001) { return; } // 커서가 가리키는 논리 지점이 화면상 같은 곳에 남도록 오프셋 보정 (레이아웃 갱신 후) var mouse = e.GetPosition(Scroll); var factor = newZoom / oldZoom; Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, () => { Scroll.ScrollToHorizontalOffset((Scroll.HorizontalOffset + mouse.X) * factor - mouse.X); Scroll.ScrollToVerticalOffset((Scroll.VerticalOffset + mouse.Y) * factor - mouse.Y); }); } #endregion }