목업대로 다시 짰다. 색·모서리·간격은 SheetMe 토큰을 그대로 쓴다. ■ 레이아웃 머리말(제목 + 대상 칩) / SQL 편집기 카드 / 치환 변수 카드 / 아래 버튼 줄. 편집기 카드에는 툴바(포맷·주석·자동완성 · 전체 지우기)와 상태 줄(연결 상태 · 글자·줄 수 · 줄,열)을 뒀다. 변수 카드에는 검색 + 갈래 칩 + 접이식 그룹 + 행마다 + 버튼을 뒀다. + 버튼을 넣은 이유는 더블클릭이 유일한 삽입 수단이었기 때문이다 — 화면에 드러나지 않는 조작이다. ■ 《컨트롤명》 치환 — 아예 없던 기능 레거시 화면에는 Controls 목록이 따로 있었다. 우리에겐 그 문법 자체가 없었다. clsMDataTable.ConvertQuery 는 <<…>> 를 모두 치환한 뒤 《 로 다시 쪼개 poAllControlOnSheet(이름).GETVALUE 를 박는다(:78-94). 즉 같은 서식의 다른 컨트롤 값을 쿼리 조건에 넣을 수 있다. 이제 이 서식의 컨트롤이 '컨트롤' 갈래로 목록에 뜨고, 토크나이저도 《》 를 변수로 잡아 색이 붙고 검증에 걸린다. 다만 디자인 시점에는 컨트롤 목록이 없어 런타임이 무조건 "0" 으로 치환한다(:86) — 검증 실행이 통과해도 운영에서는 다른 값이 들어간다는 뜻이라 코드에 적어 뒀다. ■ 형식(String/Int32/DataRow) 표시 레거시가 '목록/형식' 두 열로 보여 주던 정보다. DataRow 인지 아닌지가 곧 '컬럼명을 더 채워야 하는가'를 뜻해서 고를 때 필요하다. ■ 갈래 칩 그룹이 일곱이라 칩으로 다 늘어놓으면 그것대로 목록이 된다. '값이 어디서 오는가'로 네 갈래(환자·서식·컨트롤·작업자)로 묶고, 같은 칩을 다시 누르면 해제된다. ■ 포맷(Ctrl+Shift+F 상당 — 툴바 버튼) 예약어·함수를 대문자로 바꾸고 주요 절 앞에서 줄을 나누며 괄호 깊이만큼 들여쓴다. 문자열·주석·치환 변수는 손대지 않는다 — 특히 치환 변수는 대소문자가 맞아야 런타임이 찾으므로 대문자로 바꾸면 그 칸이 조용히 빈다. 이 포매터가 저지를 수 있는 가장 나쁜 일이라 테스트로 막았다. 두 번 눌러도 결과가 같다(안정성도 테스트로 고정). 단위 테스트 14건 추가(포매터 11 · 컨트롤 치환 3). 회귀: 테스트 230/230, 편집 스모크 실패 0, 팝업 점검 7/7, 검증 실행 점검 10/10, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
367 lines
13 KiB
C#
367 lines
13 KiB
C#
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;
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 페이지 머리가 화면 위쪽에 오도록 스크롤 — 레이어 아웃라인의 페이지 헤더 클릭 경로.
|
|
/// 캔버스가 전 페이지를 하나의 월드에 세로로 쌓아 그리므로 페이지 오프셋만 알면 된다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 컨트롤이 화면 가운데 오도록 스크롤 — 레이어 목록 더블클릭 경로.
|
|
///
|
|
/// 월드 좌표 → 화면 좌표 변환은 World 그리드의 LayoutTransform(줌)과 감싸는 Border 의
|
|
/// Padding 24 를 모두 반영해야 한다. 이미 보이는 컨트롤은 건드리지 않는다 —
|
|
/// 목록을 훑을 때마다 화면이 튀면 오히려 방향 감각을 잃는다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>뷰포트 크기를 VM 에 전달 — 화면 맞춤(ZoomFit)이 시각 트리를 몰라도 되게 한다</summary>
|
|
private void OnScrollSizeChanged(object sender, SizeChangedEventArgs e)
|
|
=> subscribedDesigner?.ReportViewport(Scroll.ViewportWidth, Scroll.ViewportHeight);
|
|
|
|
/// <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,
|
|
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 - 손(팬) 도구
|
|
/// <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
|
|
}
|