초기 커밋: SheetMe 서식생성기 (P0~P5 완료 상태)
레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너. 기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49. 이 커밋에 함께 포함된 자격증명 분리: - appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환 - 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외, csproj Debug 조건부 복사라 Release 산출물에 실리지 않음) - ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경, 미치환 플레이스홀더는 '미설정'으로 간주 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 디자인 캔버스 뷰 — 코드비하인드 허용 범위: ①줌 휠 처리(커서 중심 스크롤 보정) ②캔버스 포커스 ③인라인 텍스트 에디터.
|
||||
/// 문서 변경 로직은 두지 않는다(커밋은 DesignerViewModel.CommitInlineText 경유).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
CloseInlineEditor(commit: false);
|
||||
subscribedDesigner = e.NewValue as DesignerViewModel;
|
||||
if (subscribedDesigner is not null)
|
||||
{
|
||||
subscribedDesigner.InlineEditRequested += ShowInlineEditor;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>대상 컨트롤 위에 편집기 표시 — 폰트/정렬 일치, 줌은 World LayoutTransform 이 처리</summary>
|
||||
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)
|
||||
{
|
||||
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 - 손(팬) 도구
|
||||
/// <summary>손 도구 활성 여부 — 플로팅 바 토글([200] 관행) 또는 Space 누르는 동안</summary>
|
||||
private bool IsHandToolActive =>
|
||||
spacePanHeld
|
||||
|| (Application.Current.MainWindow?.DataContext as MainViewModel)?.IsHandTool == true;
|
||||
|
||||
/// <summary>팬 시작 — Preview 단계 선점으로 하위 선택/이동 로직(World 버블링) 차단</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Space 누르는 동안 임시 손 도구 — 텍스트 입력 중에는 개입하지 않음</summary>
|
||||
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 - 줌
|
||||
/// <summary>Ctrl+휠 줌 — 커서 논리 위치를 유지하도록 스크롤 오프셋 보정</summary>
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user