초기 커밋: 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,16 @@
|
||||
<Application x:Class="SheetMe.Designer.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<!-- 타입별 렌더 템플릿 — 캔버스/미리보기/인쇄 공유 -->
|
||||
<ResourceDictionary Source="/Controls/ControlTemplates.xaml"/>
|
||||
<!-- [200]SheetMe 디자인 시스템 — 암시 스타일 + 키 스타일({DynamicResource B.*} 토큰 참조) -->
|
||||
<ResourceDictionary Source="/Themes/DesignerTheme.xaml"/>
|
||||
<!-- 색 토큰 — ThemeManager 가 Tokens.Dark/Light 를 Source 로 식별해 런타임 교체 -->
|
||||
<ResourceDictionary Source="/Themes/Tokens.Dark.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.Views;
|
||||
|
||||
namespace SheetMe.Designer;
|
||||
|
||||
/// <summary>
|
||||
/// 서식생성기 애플리케이션 진입점.
|
||||
/// 명령행: --render-smoke <입력.xml> <출력.png> — 첫 페이지를 오프스크린 렌더해 PNG 저장(검증용) 후 종료.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
/// <summary>기동 — 스모크 모드 분기 또는 메인 셸 표시</summary>
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--render-smoke")
|
||||
{
|
||||
var exitCode = RunRenderSmoke(e.Args[1], e.Args[2]);
|
||||
Shutdown(exitCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 2 && e.Args[0] == "--edit-smoke")
|
||||
{
|
||||
Shutdown(Diagnostics.EditSmoke.Run(e.Args[1]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 2 && e.Args[0] == "--db-smoke")
|
||||
{
|
||||
var max = e.Args.Length >= 3 && int.TryParse(e.Args[2], out var n) ? n : 30;
|
||||
Shutdown(Diagnostics.DbSmoke.RunReadSmoke(e.Args[1], max));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-save-smoke")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunSaveSmoke(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-find")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunFind(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-columns")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunColumns(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-row")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunRow(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-sample")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunSample(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-word-smoke")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunWordSmoke(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-spd")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunSpreadDump(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-xml")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunDesignXml(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-render")
|
||||
{
|
||||
Shutdown(RunDbRenderSmoke(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
Services.ThemeManager.LoadSaved();
|
||||
new MainView().Show();
|
||||
}
|
||||
|
||||
/// <summary>첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증)</summary>
|
||||
private static int RunRenderSmoke(string xmlPath, string pngPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var store = new Data.Stores.XmlFileFormStore();
|
||||
var document = store.LoadFile(xmlPath);
|
||||
var page = DocumentMapper.CreatePage(document.Pages[0], 0);
|
||||
|
||||
var view = new PageView { DataContext = page };
|
||||
var size = new Size(page.WidthDip, page.HeightDip);
|
||||
view.Measure(size);
|
||||
view.Arrange(new Rect(size));
|
||||
view.UpdateLayout();
|
||||
|
||||
var bitmap = new RenderTargetBitmap(
|
||||
(int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height), 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(view);
|
||||
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
using var stream = File.Create(pngPath);
|
||||
encoder.Save(stream);
|
||||
|
||||
Console.WriteLine($"렌더 스모크 완료: {pngPath} ({size.Width}x{size.Height}, " +
|
||||
$"페이지 {document.Pages.Count}, 경고 {document.Meta.ReadWarnings.Count})");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"렌더 스모크 실패: {ex}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>DB 서식 첫 페이지 오프스크린 렌더 → PNG (Spread 격자 주입 경로 포함 검증)</summary>
|
||||
private static int RunDbRenderSmoke(string shtCod, string pngPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataBusiness = new DataBusiness.FormDesignDataBusiness();
|
||||
var document = dataBusiness.OpenFromDb(shtCod);
|
||||
if (document is null)
|
||||
{
|
||||
File.WriteAllText(pngPath + ".err.txt", $"활성 디자인 없음: {shtCod}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var designer = new ViewModels.DesignerViewModel(document, dataBusiness.LoadSpreadGrids(shtCod));
|
||||
var page = designer.Pages[0];
|
||||
|
||||
var view = new PageView { DataContext = page };
|
||||
var size = new Size(page.WidthDip, page.HeightDip);
|
||||
view.Measure(size);
|
||||
view.Arrange(new Rect(size));
|
||||
view.UpdateLayout();
|
||||
|
||||
var bitmap = new RenderTargetBitmap(
|
||||
(int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height), 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(view);
|
||||
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
using var stream = File.Create(pngPath);
|
||||
encoder.Save(stream);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(pngPath + ".err.txt", ex.ToString());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,48 @@
|
||||
using System.Windows;
|
||||
using Microsoft.Xaml.Behaviors;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Behaviors;
|
||||
|
||||
/// <summary>캔버스 드롭 수신 — 팔레트 타입을 월드 좌표에 배치.</summary>
|
||||
public sealed class CanvasDropBehavior : Behavior<FrameworkElement>
|
||||
{
|
||||
#region Methods
|
||||
protected override void OnAttached()
|
||||
{
|
||||
base.OnAttached();
|
||||
AssociatedObject.AllowDrop = true;
|
||||
AssociatedObject.DragOver += OnDragOver;
|
||||
AssociatedObject.Drop += OnDrop;
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
AssociatedObject.DragOver -= OnDragOver;
|
||||
AssociatedObject.Drop -= OnDrop;
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
private void OnDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
e.Effects = e.Data.GetDataPresent(PaletteDragBehavior.DataFormat)
|
||||
? DragDropEffects.Copy
|
||||
: DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (AssociatedObject.DataContext is not DesignerViewModel designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (e.Data.GetData(PaletteDragBehavior.DataFormat) is not string type)
|
||||
{
|
||||
return;
|
||||
}
|
||||
designer.AddControlAt(type, e.GetPosition(AssociatedObject));
|
||||
e.Handled = true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using Microsoft.Xaml.Behaviors;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// 캔버스 키보드 단축키 — 텍스트 입력 요소에 포커스가 있으면 통과(입력 보호).
|
||||
/// 화살표 넛지(Shift=그리드), Del, Ctrl+A/C/V/D/Z/Y, Esc.
|
||||
/// </summary>
|
||||
public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
|
||||
{
|
||||
#region Methods
|
||||
protected override void OnAttached()
|
||||
{
|
||||
base.OnAttached();
|
||||
AssociatedObject.PreviewKeyDown += OnPreviewKeyDown;
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
AssociatedObject.PreviewKeyDown -= OnPreviewKeyDown;
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if ((AssociatedObject as FrameworkElement)?.DataContext is not DesignerViewModel designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 텍스트 입력 보호 — 포커스가 편집 컨트롤이면 단축키 라우팅 안 함
|
||||
if (Keyboard.FocusedElement is TextBoxBase or ComboBox or PasswordBox)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
|
||||
var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
|
||||
var step = shift ? designer.Snap.GridSize * 2 : 1;
|
||||
var handled = true;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Left: designer.NudgeSelection(-step, 0); break;
|
||||
case Key.Right: designer.NudgeSelection(step, 0); break;
|
||||
case Key.Up: designer.NudgeSelection(0, -step); break;
|
||||
case Key.Down: designer.NudgeSelection(0, step); break;
|
||||
case Key.Delete: designer.DeleteSelection(); break;
|
||||
case Key.Escape: designer.Interaction.Cancel(); break;
|
||||
case Key.A when ctrl: designer.SelectAllOnActivePage(); break;
|
||||
case Key.C when ctrl: designer.CopySelection(); break;
|
||||
case Key.X when ctrl: designer.CutSelection(); break;
|
||||
case Key.V when ctrl: designer.Paste(); break;
|
||||
case Key.D when ctrl: designer.CopySelection(); designer.Paste(); break;
|
||||
case Key.Z when ctrl: designer.Undo.Undo(); break;
|
||||
case Key.Y when ctrl: designer.Undo.Redo(); break;
|
||||
case Key.G when ctrl && shift: designer.UngroupSelection(); break;
|
||||
case Key.G when ctrl: designer.GroupSelection(); break;
|
||||
default: handled = false; break;
|
||||
}
|
||||
|
||||
e.Handled = handled;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using Microsoft.Xaml.Behaviors;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// 캔버스 마우스 입력 → 월드 좌표 → InteractionController 위임.
|
||||
/// World 요소(LayoutTransform 줌 하위)에 부착 — GetPosition(World)가 줌 불변 논리좌표를 반환.
|
||||
/// 로직 없음: 좌표 변환·캡처·커서 매핑만 담당.
|
||||
/// </summary>
|
||||
public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
|
||||
{
|
||||
#region Methods
|
||||
protected override void OnAttached()
|
||||
{
|
||||
base.OnAttached();
|
||||
AssociatedObject.MouseLeftButtonDown += OnMouseDown;
|
||||
AssociatedObject.MouseMove += OnMouseMove;
|
||||
AssociatedObject.MouseLeftButtonUp += OnMouseUp;
|
||||
AssociatedObject.LostMouseCapture += OnLostCapture;
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
AssociatedObject.MouseLeftButtonDown -= OnMouseDown;
|
||||
AssociatedObject.MouseMove -= OnMouseMove;
|
||||
AssociatedObject.MouseLeftButtonUp -= OnMouseUp;
|
||||
AssociatedObject.LostMouseCapture -= OnLostCapture;
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
private DesignerViewModel? Designer => AssociatedObject.DataContext as DesignerViewModel;
|
||||
|
||||
private static PointerContext ContextOf(int clickCount = 1) => new(
|
||||
Keyboard.Modifiers.HasFlag(ModifierKeys.Control),
|
||||
Keyboard.Modifiers.HasFlag(ModifierKeys.Shift),
|
||||
Keyboard.Modifiers.HasFlag(ModifierKeys.Alt),
|
||||
clickCount);
|
||||
|
||||
private void OnMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (Designer is not { } designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 키보드 포커스를 캔버스 컨테이너(Focusable 상위)로 — 단축키 수신
|
||||
FocusCanvasHost();
|
||||
|
||||
designer.Interaction.PointerDown(e.GetPosition(AssociatedObject), ContextOf(e.ClickCount));
|
||||
AssociatedObject.CaptureMouse();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (Designer is not { } designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var world = e.GetPosition(AssociatedObject);
|
||||
if (e.LeftButton == MouseButtonState.Pressed && AssociatedObject.IsMouseCaptured)
|
||||
{
|
||||
designer.Interaction.PointerMove(world, ContextOf());
|
||||
}
|
||||
AssociatedObject.Cursor = MapCursor(designer.Interaction.CursorAt(world));
|
||||
}
|
||||
|
||||
private void OnMouseUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (Designer is not { } designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
designer.Interaction.PointerUp(e.GetPosition(AssociatedObject), ContextOf());
|
||||
AssociatedObject.ReleaseMouseCapture();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnLostCapture(object sender, MouseEventArgs e)
|
||||
=> Designer?.Interaction.CancelDrag(); // 정상 업 이후에는 no-op — 선택 유지
|
||||
|
||||
private void FocusCanvasHost()
|
||||
{
|
||||
// 상위 DesignerCanvasView(Focusable)로 포커스 — 키보드 단축키 수신
|
||||
DependencyObject? current = AssociatedObject;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is FrameworkElement { Focusable: true } focusable)
|
||||
{
|
||||
focusable.Focus();
|
||||
return;
|
||||
}
|
||||
current = System.Windows.Media.VisualTreeHelper.GetParent(current);
|
||||
}
|
||||
}
|
||||
|
||||
private static Cursor MapCursor(CursorKind kind) => kind switch
|
||||
{
|
||||
CursorKind.SizeAll => Cursors.SizeAll,
|
||||
CursorKind.SizeNWSE => Cursors.SizeNWSE,
|
||||
CursorKind.SizeNESW => Cursors.SizeNESW,
|
||||
CursorKind.SizeNS => Cursors.SizeNS,
|
||||
CursorKind.SizeWE => Cursors.SizeWE,
|
||||
_ => Cursors.Arrow,
|
||||
};
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using Microsoft.Xaml.Behaviors;
|
||||
using SheetMe.Core.Catalog;
|
||||
|
||||
namespace SheetMe.Designer.Behaviors;
|
||||
|
||||
/// <summary>팔레트 항목 드래그 시작 — 5px 임계 후 DoDragDrop("SheetMe.ControlType").</summary>
|
||||
public sealed class PaletteDragBehavior : Behavior<ListBox>
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>드래그 데이터 포맷 키</summary>
|
||||
public const string DataFormat = "SheetMe.ControlType";
|
||||
|
||||
private Point downPoint;
|
||||
private ControlDescriptor? downItem;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
protected override void OnAttached()
|
||||
{
|
||||
base.OnAttached();
|
||||
AssociatedObject.PreviewMouseLeftButtonDown += OnDown;
|
||||
AssociatedObject.PreviewMouseMove += OnMove;
|
||||
AssociatedObject.MouseDoubleClick += OnDoubleClick;
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
AssociatedObject.PreviewMouseLeftButtonDown -= OnDown;
|
||||
AssociatedObject.PreviewMouseMove -= OnMove;
|
||||
AssociatedObject.MouseDoubleClick -= OnDoubleClick;
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
/// <summary>더블클릭 즉시 배치 — 레거시 툴박스 UX(활성 페이지 중앙 부근)</summary>
|
||||
private void OnDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if ((e.OriginalSource as FrameworkElement)?.DataContext is not ControlDescriptor item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Application.Current.MainWindow?.DataContext is not ViewModels.MainViewModel main
|
||||
|| main.CurrentDesigner is not { } designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
designer.AddPaletteItemAtCenter(item.Type);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
downPoint = e.GetPosition(AssociatedObject);
|
||||
downItem = (e.OriginalSource as FrameworkElement)?.DataContext as ControlDescriptor;
|
||||
}
|
||||
|
||||
private void OnMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (downItem is null || e.LeftButton != MouseButtonState.Pressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var position = e.GetPosition(AssociatedObject);
|
||||
if (Math.Abs(position.X - downPoint.X) < 5 && Math.Abs(position.Y - downPoint.Y) < 5)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var item = downItem;
|
||||
downItem = null;
|
||||
DragDrop.DoDragDrop(AssociatedObject, new DataObject(DataFormat, item.Type), DragDropEffects.Copy);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vmc="clr-namespace:SheetMe.Designer.ViewModels.Controls">
|
||||
|
||||
<!-- 타입별 렌더 템플릿 — 캔버스/미리보기/인쇄가 공유하는 단일 원천.
|
||||
레거시 WinForms 렌더 모양의 시각 근사. 콘텐츠는 히트테스트 제외(입력은 월드 파이프라인 단일 수신). -->
|
||||
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
|
||||
|
||||
<!-- 라벨 -->
|
||||
<DataTemplate DataType="{x:Type vmc:LabelViewModel}">
|
||||
<Grid Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<TextBlock Text="{Binding Text}"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" FontStyle="{Binding FontStyle}"
|
||||
Foreground="{Binding Foreground}"
|
||||
HorizontalAlignment="{Binding TextAlignment}"
|
||||
VerticalAlignment="Top"
|
||||
TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 텍스트박스 -->
|
||||
<DataTemplate DataType="{x:Type vmc:TextBoxViewModel}">
|
||||
<Border Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="BorderBrush" Value="#7F9DB9"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ShowBorder}" Value="False">
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<TextBlock Text="{Binding Text}" Margin="2,1"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" FontStyle="{Binding FontStyle}"
|
||||
Foreground="{Binding Foreground}"
|
||||
HorizontalAlignment="{Binding TextAlignment}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="NoWrap"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Multiline}" Value="True">
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="VerticalAlignment" Value="Top"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 마스크 입력 -->
|
||||
<DataTemplate DataType="{x:Type vmc:MaskedTextBoxViewModel}">
|
||||
<Border Background="{Binding Background}" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
|
||||
<TextBlock Margin="2,1" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
Foreground="{Binding Foreground}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Text" Value="{Binding Text}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Text}" Value="">
|
||||
<Setter Property="Text" Value="{Binding Mask}"/>
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 체크박스 -->
|
||||
<DataTemplate DataType="{x:Type vmc:CheckBoxViewModel}">
|
||||
<StackPanel Orientation="Horizontal" Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<Border Width="13" Height="13" Background="White" BorderBrush="#5A5A5A" BorderThickness="1"
|
||||
VerticalAlignment="Center" Margin="0,0,4,0">
|
||||
<Path Data="M1,6 L5,10 L11,2" Stroke="#1E5AA0" StrokeThickness="2"
|
||||
Visibility="{Binding IsChecked, Converter={StaticResource BoolToVisibility}}"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Text}" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 라디오버튼 -->
|
||||
<DataTemplate DataType="{x:Type vmc:RadioButtonViewModel}">
|
||||
<StackPanel Orientation="Horizontal" Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<Grid Width="13" Height="13" VerticalAlignment="Center" Margin="0,0,4,0">
|
||||
<Ellipse Fill="White" Stroke="#5A5A5A" StrokeThickness="1"/>
|
||||
<Ellipse Width="6" Height="6" Fill="#1E5AA0"
|
||||
Visibility="{Binding IsChecked, Converter={StaticResource BoolToVisibility}}"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding Text}" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 콤보박스 -->
|
||||
<DataTemplate DataType="{x:Type vmc:ComboBoxViewModel}">
|
||||
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Right" Width="17" Background="#E1E1E1">
|
||||
<Path Data="M0,0 L8,0 L4,5 Z" Fill="#5A5A5A"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding DisplayText}" Margin="3,1" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
Foreground="{Binding Foreground}" TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 리스트박스 -->
|
||||
<DataTemplate DataType="{x:Type vmc:ListBoxViewModel}">
|
||||
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
|
||||
<ItemsControl ItemsSource="{Binding Items}" Margin="2">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" FontSize="12"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 체크리스트 -->
|
||||
<DataTemplate DataType="{x:Type vmc:CheckListViewModel}">
|
||||
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
|
||||
<ItemsControl ItemsSource="{Binding Items}" Margin="2">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border Width="11" Height="11" Background="White" BorderBrush="#5A5A5A"
|
||||
BorderThickness="1" Margin="0,1,4,1"/>
|
||||
<TextBlock Text="{Binding}" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 날짜선택 -->
|
||||
<DataTemplate DataType="{x:Type vmc:DateTimePickerViewModel}">
|
||||
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Right" Width="17" Background="#E1E1E1">
|
||||
<Path Data="M0,0 L8,0 L4,5 Z" Fill="#5A5A5A"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding DisplayText}" Margin="3,1" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
Foreground="{Binding Foreground}" TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 패널 (컨테이너 — 자식 재귀 렌더) -->
|
||||
<DataTemplate DataType="{x:Type vmc:PanelViewModel}">
|
||||
<Grid Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<Border BorderBrush="#A0A0A0">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ShowBorder}" Value="True">
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
</Border>
|
||||
<ItemsControl ItemsSource="{Binding Children}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
<Setter Property="Width" Value="{Binding Width}"/>
|
||||
<Setter Property="Height" Value="{Binding Height}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 그룹박스 (컨테이너) -->
|
||||
<DataTemplate DataType="{x:Type vmc:GroupBoxViewModel}">
|
||||
<Grid Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<Border BorderBrush="#B5B5B5" BorderThickness="1" CornerRadius="3" Margin="0,7,0,0"/>
|
||||
<TextBlock Text="{Binding Text}" Margin="8,0,0,0" Padding="3,0"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
Background="{Binding Background}"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"/>
|
||||
<ItemsControl ItemsSource="{Binding Children}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
<Setter Property="Width" Value="{Binding Width}"/>
|
||||
<Setter Property="Height" Value="{Binding Height}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 선 -->
|
||||
<DataTemplate DataType="{x:Type vmc:LineViewModel}">
|
||||
<Rectangle Fill="{Binding LineBrush}" IsHitTestVisible="False"/>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 이미지 -->
|
||||
<DataTemplate DataType="{x:Type vmc:PictureBoxViewModel}">
|
||||
<Border Background="#F5F7FA" BorderBrush="#C8D0DA" BorderThickness="1" IsHitTestVisible="False">
|
||||
<TextBlock Text="{Binding Hint}" FontSize="10" Foreground="#8090A0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 계산박스 -->
|
||||
<DataTemplate DataType="{x:Type vmc:CalcBoxViewModel}">
|
||||
<Border Background="#FFFDF0" BorderBrush="#C9B458" BorderThickness="1" IsHitTestVisible="False">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Left" Text="ƒ" Margin="3,0,2,0" Foreground="#A08A2A"
|
||||
FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Text}" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
Foreground="{Binding Foreground}"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 버튼 (레거시 MButton — 표준 버튼 근사) -->
|
||||
<DataTemplate DataType="{x:Type vmc:ButtonViewModel}">
|
||||
<Border CornerRadius="3" BorderBrush="#8A9AAB" BorderThickness="1" IsHitTestVisible="False">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#FDFDFD" Offset="0"/>
|
||||
<GradientStop Color="#E8ECF0" Offset="0.5"/>
|
||||
<GradientStop Color="#D8DEE6" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding Text}"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding ActionHint}" FontSize="8" Foreground="#7A8AA0"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,3,1"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 데이터소스 (레거시 MDataTable — 디자인 전용 배지, 런타임/인쇄 비가시) -->
|
||||
<DataTemplate DataType="{x:Type vmc:DataTableViewModel}">
|
||||
<Border Background="#EAF2FB" BorderBrush="#5B8DEF" BorderThickness="1" CornerRadius="4"
|
||||
IsHitTestVisible="False" ToolTip="{Binding QuerySummary}">
|
||||
<Grid>
|
||||
<!-- DB 원통 아이콘 -->
|
||||
<Path Fill="#5B8DEF" Stretch="Uniform" Margin="6"
|
||||
Data="M4,3 C4,1.9 7.6,1 12,1 C16.4,1 20,1.9 20,3 L20,17 C20,18.1 16.4,19 12,19 C7.6,19 4,18.1 4,17 Z M4,3 C4,4.1 7.6,5 12,5 C16.4,5 20,4.1 20,3 M4,8 C4,9.1 7.6,10 12,10 C16.4,10 20,9.1 20,8 M4,13 C4,14.1 7.6,15 12,15 C16.4,15 20,14.1 20,13"/>
|
||||
<TextBlock Text="{Binding Id}" FontSize="7" Foreground="#3E6BC4"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,1"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 표 (레거시 Spread — E_SpdMst 격자 렌더, 읽기 전용) -->
|
||||
<DataTemplate DataType="{x:Type vmc:SpreadViewModel}">
|
||||
<Border BorderBrush="#8A9AAB" BorderThickness="1" Background="White" IsHitTestVisible="False">
|
||||
<Grid>
|
||||
<!-- 격자선 -->
|
||||
<ItemsControl ItemsSource="{Binding GridLines}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Line X1="{Binding X1}" Y1="{Binding Y1}" X2="{Binding X2}" Y2="{Binding Y2}"
|
||||
Stroke="#C6CDD6" StrokeThickness="1"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 셀 텍스트(스팬 반영) -->
|
||||
<ItemsControl ItemsSource="{Binding CellViews}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Width="{Binding W}" Height="{Binding H}">
|
||||
<TextBlock Text="{Binding Text}"
|
||||
FontFamily="{Binding DataContext.FontFamily, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
FontSize="{Binding DataContext.FontSize, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" TextWrapping="Wrap"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 격자 정보 없음(파일 열기 등) 안내 -->
|
||||
<TextBlock Text="표 (Spread — DB에서 열면 격자 표시)" FontSize="10" Foreground="#8090A0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasGrid}" Value="False">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 미지원 컨트롤 자리표시 (빗금 + 원본 타입명) -->
|
||||
<DataTemplate DataType="{x:Type vmc:PlaceholderViewModel}">
|
||||
<Border BorderBrush="#D08080" BorderThickness="1" IsHitTestVisible="False">
|
||||
<Border.Background>
|
||||
<DrawingBrush TileMode="Tile" Viewport="0,0,8,8" ViewportUnits="Absolute">
|
||||
<DrawingBrush.Drawing>
|
||||
<GeometryDrawing Geometry="M0,8 L8,0">
|
||||
<GeometryDrawing.Pen>
|
||||
<Pen Brush="#30D08080" Thickness="1"/>
|
||||
</GeometryDrawing.Pen>
|
||||
</GeometryDrawing>
|
||||
</DrawingBrush.Drawing>
|
||||
</DrawingBrush>
|
||||
</Border.Background>
|
||||
<TextBlock Text="{Binding LegacyClassName}" FontSize="10" Foreground="#B06060"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
namespace SheetMe.Designer.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Lucide(ISC) 아이콘을 WPF 로 렌더 — SVG(24x24, stroke 2, round) inner 마크업을
|
||||
/// Geometry 로 파싱해 Viewbox+Path 로 그린다. 외부 패키지 불필요.
|
||||
/// </summary>
|
||||
public static class LucideIcons
|
||||
{
|
||||
private static readonly Dictionary<string, string> Data = new(StringComparer.Ordinal)
|
||||
{
|
||||
["file-text"] = """<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" />""",
|
||||
["layout-dashboard"] = """<rect width="7" height="9" x="3" y="3" rx="1" /> <rect width="7" height="5" x="14" y="3" rx="1" /> <rect width="7" height="9" x="14" y="12" rx="1" /> <rect width="7" height="5" x="3" y="16" rx="1" />""",
|
||||
["pencil-ruler"] = """<path d="M13 7 8.7 2.7a2.41 2.41 0 0 0-3.4 0L2.7 5.3a2.41 2.41 0 0 0 0 3.4L7 13" /> <path d="m8 6 2-2" /> <path d="m18 16 2-2" /> <path d="m17 11 4.3 4.3c.94.94.94 2.46 0 3.4l-2.6 2.6c-.94.94-2.46.94-3.4 0L11 17" /> <path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" />""",
|
||||
["settings"] = """<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" />""",
|
||||
["eye"] = """<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" /> <circle cx="12" cy="12" r="3" />""",
|
||||
["save"] = """<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" /> <path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" /> <path d="M7 3v4a1 1 0 0 0 1 1h7" />""",
|
||||
["folder-open"] = """<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />""",
|
||||
["plus"] = """<path d="M5 12h14" /> <path d="M12 5v14" />""",
|
||||
["trash-2"] = """<path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />""",
|
||||
["link"] = """<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />""",
|
||||
["sigma"] = """<path d="M18 7V5a1 1 0 0 0-1-1H6.5a.5.5 0 0 0-.4.8l4.5 6a2 2 0 0 1 0 2.4l-4.5 6a.5.5 0 0 0 .4.8H17a1 1 0 0 0 1-1v-2" />""",
|
||||
["table"] = """<path d="M12 3v18" /> <rect width="18" height="18" x="3" y="3" rx="2" /> <path d="M3 9h18" /> <path d="M3 15h18" />""",
|
||||
["printer"] = """<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" /> <path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6" /> <rect x="6" y="14" width="12" height="8" rx="1" />""",
|
||||
["pen-line"] = """<path d="M13 21h8" /> <path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />""",
|
||||
["building-2"] = """<path d="M10 12h4" /> <path d="M10 8h4" /> <path d="M14 21v-3a2 2 0 0 0-4 0v3" /> <path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2" /> <path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16" />""",
|
||||
["package"] = """<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" /> <path d="M12 22V12" /> <polyline points="3.29 7 12 12 20.71 7" /> <path d="m7.5 4.27 9 5.15" />""",
|
||||
["key-round"] = """<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" /> <circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />""",
|
||||
["scroll-text"] = """<path d="M15 12h-5" /> <path d="M15 8h-5" /> <path d="M19 17V5a2 2 0 0 0-2-2H4" /> <path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" />""",
|
||||
["search"] = """<path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" />""",
|
||||
["circle-check"] = """<circle cx="12" cy="12" r="10" /> <path d="m9 12 2 2 4-4" />""",
|
||||
["type"] = """<path d="M12 4v16" /> <path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" /> <path d="M9 20h6" />""",
|
||||
["text-cursor-input"] = """<path d="M5 4h1a3 3 0 0 1 3 3 3 3 0 0 1 3-3h1" /> <path d="M13 20h-1a3 3 0 0 1-3-3 3 3 0 0 1-3 3H5" /> <path d="M5 16H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h1" /> <path d="M13 8h7a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-7" /> <path d="M9 7v10" />""",
|
||||
["calendar"] = """<path d="M8 2v4" /> <path d="M16 2v4" /> <rect width="18" height="18" x="3" y="4" rx="2" /> <path d="M3 10h18" />""",
|
||||
["list"] = """<path d="M3 5h.01" /> <path d="M3 12h.01" /> <path d="M3 19h.01" /> <path d="M8 5h13" /> <path d="M8 12h13" /> <path d="M8 19h13" />""",
|
||||
["square-check"] = """<rect width="18" height="18" x="3" y="3" rx="2" /> <path d="m9 12 2 2 4-4" />""",
|
||||
["circle-dot"] = """<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="1" />""",
|
||||
["heading"] = """<path d="M6 12h12" /> <path d="M6 20V4" /> <path d="M18 20V4" />""",
|
||||
["check"] = """<path d="M20 6 9 17l-5-5" />""",
|
||||
["chevron-right"] = """<path d="m9 18 6-6-6-6" />""",
|
||||
["chevron-left"] = """<path d="m15 18-6-6 6-6" />""",
|
||||
["chevron-down"] = """<path d="m6 9 6 6 6-6" />""",
|
||||
["layout-template"] = """<rect width="18" height="7" x="3" y="3" rx="1" /> <rect width="9" height="7" x="3" y="14" rx="1" /> <rect width="5" height="7" x="16" y="14" rx="1" />""",
|
||||
["download"] = """<path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" />""",
|
||||
["pencil"] = """<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" />""",
|
||||
["folder"] = """<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />""",
|
||||
["minus"] = """<path d="M5 12h14" />""",
|
||||
["image"] = """<rect width="18" height="18" x="3" y="3" rx="2" ry="2" /> <circle cx="9" cy="9" r="2" /> <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />""",
|
||||
["mouse-pointer-click"] = """<path d="M14 4.1 12 6" /> <path d="m5.1 8-2.9-.8" /> <path d="m6 12-1.9 2" /> <path d="M7.2 2.2 8 5.1" /> <path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z" />""",
|
||||
["list-checks"] = """<path d="m3 17 2 2 4-4" /> <path d="m3 7 2 2 4-4" /> <path d="M13 6h8" /> <path d="M13 12h8" /> <path d="M13 18h8" />""",
|
||||
["box"] = """<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /> <path d="m3.3 7 8.7 5 8.7-5" /> <path d="M12 22V12" />""",
|
||||
["align-left"] = """<line x1="21" x2="3" y1="6" y2="6" /> <line x1="15" x2="3" y1="12" y2="12" /> <line x1="17" x2="3" y1="18" y2="18" />""",
|
||||
["align-center"] = """<line x1="21" x2="3" y1="6" y2="6" /> <line x1="17" x2="7" y1="12" y2="12" /> <line x1="19" x2="5" y1="18" y2="18" />""",
|
||||
["align-right"] = """<line x1="21" x2="3" y1="6" y2="6" /> <line x1="21" x2="9" y1="12" y2="12" /> <line x1="21" x2="7" y1="18" y2="18" />""",
|
||||
["bold"] = """<path d="M14 12a4 4 0 0 0 0-8H6v8" /> <path d="M15 20a4 4 0 0 0 0-8H6v8Z" />""",
|
||||
["italic"] = """<line x1="19" x2="10" y1="4" y2="4" /> <line x1="14" x2="5" y1="20" y2="20" /> <line x1="15" x2="9" y1="4" y2="20" />""",
|
||||
["underline"] = """<path d="M6 4v6a6 6 0 0 0 12 0V4" /> <line x1="4" x2="20" y1="20" y2="20" />""",
|
||||
["zoom-in"] = """<circle cx="11" cy="11" r="8" /> <line x1="21" x2="16.65" y1="21" y2="16.65" /> <line x1="11" x2="11" y1="8" y2="14" /> <line x1="8" x2="14" y1="11" y2="11" />""",
|
||||
["zoom-out"] = """<circle cx="11" cy="11" r="8" /> <line x1="21" x2="16.65" y1="21" y2="16.65" /> <line x1="8" x2="14" y1="11" y2="11" />""",
|
||||
["maximize"] = """<path d="M8 3H5a2 2 0 0 0-2 2v3" /> <path d="M21 8V5a2 2 0 0 0-2-2h-3" /> <path d="M3 16v3a2 2 0 0 0 2 2h3" /> <path d="M16 21h3a2 2 0 0 0 2-2v-3" />""",
|
||||
["eye-off"] = """<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" /> <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" /> <path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" /> <path d="m2 2 20 20" />""",
|
||||
["copy"] = """<rect width="14" height="14" x="8" y="8" rx="2" ry="2" /> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />""",
|
||||
["clipboard"] = """<rect width="8" height="4" x="8" y="2" rx="1" ry="1" /> <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />""",
|
||||
["undo-2"] = """<path d="M9 14 4 9l5-5" /> <path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11" />""",
|
||||
["redo-2"] = """<path d="m15 14 5-5-5-5" /> <path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13" />""",
|
||||
["sun"] = """<circle cx="12" cy="12" r="4" /> <path d="M12 2v2" /> <path d="M12 20v2" /> <path d="m4.93 4.93 1.41 1.41" /> <path d="m17.66 17.66 1.41 1.41" /> <path d="M2 12h2" /> <path d="M20 12h2" /> <path d="m6.34 17.66-1.41 1.41" /> <path d="m19.07 4.93-1.41 1.41" />""",
|
||||
["moon"] = """<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />""",
|
||||
["move-horizontal"] = """<polyline points="18 8 22 12 18 16" /> <polyline points="6 8 2 12 6 16" /> <line x1="2" x2="22" y1="12" y2="12" />""",
|
||||
["move-vertical"] = """<polyline points="8 18 12 22 16 18" /> <polyline points="8 6 12 2 16 6" /> <line x1="12" x2="12" y1="2" y2="22" />""",
|
||||
["align-obj-top"] = """<line x1="3" x2="21" y1="4" y2="4" /> <rect x="6" y="7" width="4" height="9" rx="1" /> <rect x="14" y="7" width="4" height="13" rx="1" />""",
|
||||
["align-obj-middle"] = """<line x1="3" x2="21" y1="12" y2="12" /> <rect x="6" y="4" width="4" height="6" rx="1" /> <rect x="14" y="14" width="4" height="6" rx="1" />""",
|
||||
["align-obj-bottom"] = """<line x1="3" x2="21" y1="20" y2="20" /> <rect x="6" y="8" width="4" height="9" rx="1" /> <rect x="14" y="4" width="4" height="13" rx="1" />""",
|
||||
["distribute-h"] = """<rect x="3" y="7" width="3.5" height="10" rx="1" /> <rect x="10.25" y="7" width="3.5" height="10" rx="1" /> <rect x="17.5" y="7" width="3.5" height="10" rx="1" />""",
|
||||
["distribute-v"] = """<rect x="7" y="3" width="10" height="3.5" rx="1" /> <rect x="7" y="10.25" width="10" height="3.5" rx="1" /> <rect x="7" y="17.5" width="10" height="3.5" rx="1" />""",
|
||||
["square"] = """<rect width="18" height="18" x="3" y="3" rx="2" />""",
|
||||
["lock"] = """<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />""",
|
||||
["unlock"] = """<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 9.9-1" />""",
|
||||
["group"] = """<path d="M3 7V5a2 2 0 0 1 2-2h2" /> <path d="M17 3h2a2 2 0 0 1 2 2v2" /> <path d="M21 17v2a2 2 0 0 1-2 2h-2" /> <path d="M7 21H5a2 2 0 0 1-2-2v-2" /> <rect width="7" height="5" x="7" y="7" rx="1" /> <rect width="7" height="5" x="10" y="12" rx="1" />""",
|
||||
["ungroup"] = """<rect width="8" height="6" x="5" y="4" rx="1" /> <rect width="8" height="6" x="11" y="14" rx="1" />""",
|
||||
["align-obj-left"] = """<line x1="4" x2="4" y1="3" y2="21" /> <rect x="7" y="6" width="9" height="4" rx="1" /> <rect x="7" y="14" width="13" height="4" rx="1" />""",
|
||||
["align-obj-center-h"] = """<line x1="12" x2="12" y1="3" y2="21" /> <rect x="6" y="6" width="12" height="4" rx="1" /> <rect x="3" y="14" width="18" height="4" rx="1" />""",
|
||||
["align-obj-right"] = """<line x1="20" x2="20" y1="3" y2="21" /> <rect x="11" y="6" width="9" height="4" rx="1" /> <rect x="7" y="14" width="13" height="4" rx="1" />""",
|
||||
["droplet"] = """<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z" />""",
|
||||
["rotate-cw"] = """<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" />""",
|
||||
["frame"] = """<line x1="22" x2="2" y1="6" y2="6" /> <line x1="22" x2="2" y1="18" y2="18" /> <line x1="6" x2="6" y1="2" y2="22" /> <line x1="18" x2="18" y1="2" y2="22" />""",
|
||||
["filter"] = """<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />""",
|
||||
["filter-x"] = """<path d="M13.013 3H2l8 9.46V19l4 2v-8.54l.9-1.055" /> <path d="m22 3-5 5" /> <path d="m17 3 5 5" />""",
|
||||
["user"] = """<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /> <circle cx="12" cy="7" r="4" />""",
|
||||
["play"] = """<polygon points="6 3 20 12 6 21 6 3" />""",
|
||||
["menu"] = """<line x1="4" x2="20" y1="12" y2="12" /> <line x1="4" x2="20" y1="6" y2="6" /> <line x1="4" x2="20" y1="18" y2="18" />""",
|
||||
["hand"] = """<path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2" /> <path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2" /> <path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8" /> <path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" />""",
|
||||
["layout-grid"] = """<rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" />""",
|
||||
};
|
||||
|
||||
/// <summary>아이콘 + 텍스트 가로 묶음 (버튼/네비 Content 용).</summary>
|
||||
public static StackPanel Label(string icon, string text, double iconSize = 15, Brush? color = null)
|
||||
{
|
||||
var sp = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
|
||||
sp.Children.Add(Icon(icon, iconSize, color));
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
var tb = new TextBlock { Text = text, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
|
||||
if (color is not null) tb.Foreground = color; // 아이콘과 같은 색을 글자에도 적용(다크 배경서 글자 안 묻히게)
|
||||
sp.Children.Add(tb);
|
||||
}
|
||||
return sp;
|
||||
}
|
||||
|
||||
public static FrameworkElement Icon(string name, double size = 16, Brush? color = null)
|
||||
{
|
||||
color ??= Brushes.Black;
|
||||
var canvas = new Canvas { Width = 24, Height = 24 };
|
||||
if (Data.TryGetValue(name, out var markup))
|
||||
{
|
||||
var (stroke, fill) = Parse(markup);
|
||||
if (stroke.Children.Count > 0)
|
||||
canvas.Children.Add(new Path { Data = stroke, Stroke = color, StrokeThickness = 2, StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round, StrokeLineJoin = PenLineJoin.Round });
|
||||
if (fill.Children.Count > 0)
|
||||
canvas.Children.Add(new Path { Data = fill, Fill = color });
|
||||
}
|
||||
return new Viewbox { Width = size, Height = size, Child = canvas, Stretch = Stretch.Uniform, SnapsToDevicePixels = true };
|
||||
}
|
||||
|
||||
private static (GeometryGroup Stroke, GeometryGroup Fill) Parse(string markup)
|
||||
{
|
||||
var stroke = new GeometryGroup();
|
||||
var fill = new GeometryGroup();
|
||||
foreach (Match m in Regex.Matches(markup, @"<(path|rect|circle|line|polyline|polygon)\b([^>]*?)/?>", RegexOptions.Singleline))
|
||||
{
|
||||
var tag = m.Groups[1].Value;
|
||||
var attrs = m.Groups[2].Value;
|
||||
var isFill = string.Equals(Attr(attrs, "fill"), "currentColor", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Geometry? g = tag switch
|
||||
{
|
||||
"path" => SafeParse(Attr(attrs, "d")),
|
||||
"rect" => new RectangleGeometry(new Rect(D(attrs, "x"), D(attrs, "y"), D(attrs, "width"), D(attrs, "height")), D(attrs, "rx"), D(attrs, "rx")),
|
||||
"circle" => new EllipseGeometry(new Point(D(attrs, "cx"), D(attrs, "cy")), D(attrs, "r"), D(attrs, "r")),
|
||||
"line" => new LineGeometry(new Point(D(attrs, "x1"), D(attrs, "y1")), new Point(D(attrs, "x2"), D(attrs, "y2"))),
|
||||
"polyline" => PolyGeo(Attr(attrs, "points"), false),
|
||||
"polygon" => PolyGeo(Attr(attrs, "points"), true),
|
||||
_ => null,
|
||||
};
|
||||
if (g is not null) (isFill ? fill : stroke).Children.Add(g);
|
||||
}
|
||||
return (stroke, fill);
|
||||
}
|
||||
|
||||
private static string Attr(string attrs, string name)
|
||||
{
|
||||
var m = Regex.Match(attrs, name + @"\s*=\s*""([^""]*)""");
|
||||
return m.Success ? m.Groups[1].Value : string.Empty;
|
||||
}
|
||||
|
||||
private static double D(string attrs, string name)
|
||||
=> double.TryParse(Attr(attrs, name), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0;
|
||||
|
||||
private static Geometry? SafeParse(string d)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(d)) return null;
|
||||
try { return Geometry.Parse(d); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static Geometry? PolyGeo(string points, bool closed)
|
||||
{
|
||||
var nums = Regex.Split(points.Trim(), @"[\s,]+").Where(s => s.Length > 0)
|
||||
.Select(s => double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0).ToList();
|
||||
if (nums.Count < 4) return null;
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i + 1 < nums.Count; i += 2)
|
||||
sb.Append(i == 0 ? "M" : " L").Append(' ').Append(nums[i].ToString(CultureInfo.InvariantCulture)).Append(',').Append(nums[i + 1].ToString(CultureInfo.InvariantCulture));
|
||||
if (closed) sb.Append(" Z");
|
||||
return SafeParse(sb.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace SheetMe.Designer.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// 팔레트 타입 → (Lucide 아이콘, 카테고리) 매핑 — [200]SheetMe 팔레트 타일 룩 이식.
|
||||
/// Core(ControlRegistry)는 UI 무관하게 유지하고 표시 계층인 여기서만 매핑한다.
|
||||
/// </summary>
|
||||
public static class PaletteIconCatalog
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly Dictionary<string, (string Icon, string Category)> Map = new(StringComparer.Ordinal)
|
||||
{
|
||||
["Label"] = ("type", "표시"),
|
||||
["PictureBox"] = ("image", "표시"),
|
||||
["Line"] = ("minus", "표시"),
|
||||
["TextBox"] = ("text-cursor-input", "입력"),
|
||||
["MaskedTextBox"] = ("text-cursor-input", "입력"),
|
||||
["ComboBox"] = ("chevron-down", "입력"),
|
||||
["ListBox"] = ("list", "입력"),
|
||||
["CheckList"] = ("list-checks", "입력"),
|
||||
["DateTimePicker"] = ("calendar", "입력"),
|
||||
["CalcBox"] = ("sigma", "입력"),
|
||||
["CheckBox"] = ("square-check", "선택"),
|
||||
["RadioButton"] = ("circle-dot", "선택"),
|
||||
["Panel"] = ("box", "컨테이너"),
|
||||
["GroupBox"] = ("package", "컨테이너"),
|
||||
["Button"] = ("mouse-pointer-click", "동작/데이터"),
|
||||
["DataTable"] = ("table", "동작/데이터"),
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>타입의 Lucide 아이콘명(미지정 타입은 square)</summary>
|
||||
public static string IconOf(string type) => Map.TryGetValue(type, out var entry) ? entry.Icon : "square";
|
||||
|
||||
/// <summary>타입의 팔레트 카테고리(그룹 헤더)</summary>
|
||||
public static string CategoryOf(string type) => Map.TryGetValue(type, out var entry) ? entry.Category : "기타";
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>팔레트 타입명 → Lucide 아이콘 비주얼(FrameworkElement) 컨버터. parameter=크기(기본 18)</summary>
|
||||
public sealed class TypeToIconConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string type)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var size = parameter is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 18;
|
||||
var brush = System.Windows.Application.Current.TryFindResource("B.Muted") as Brush ?? Brushes.Gray;
|
||||
return LucideIcons.Icon(PaletteIconCatalog.IconOf(type), size, brush);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>팔레트 타입명 → 카테고리명 컨버터(CollectionViewSource 그룹핑용)</summary>
|
||||
public sealed class TypeToCategoryConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is string type ? PaletteIconCatalog.CategoryOf(type) : "기타";
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>불리언 반전 컨버터(선택 도구 토글 = !IsHandTool 표시용)</summary>
|
||||
public sealed class InverseBoolConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is not true;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is not true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lucide 아이콘명 → 비주얼 컨버터(문서 탭 file-text, 플로팅 바 layout-grid 등 고정 아이콘용).
|
||||
/// Binding Source 에 아이콘명 문자열을 넣어 사용 — 항목마다 새 비주얼이 생성되어 공유 부모 충돌이 없다.
|
||||
/// parameter=크기(기본 14).
|
||||
/// </summary>
|
||||
public sealed class IconNameToVisualConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string name)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var size = parameter is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 14;
|
||||
var brush = System.Windows.Application.Current.TryFindResource("B.Muted") as Brush ?? Brushes.Gray;
|
||||
return LucideIcons.Icon(name, size, brush);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Data.Config;
|
||||
using SheetMe.Data.Stores;
|
||||
using SheetMe.Designer.Services;
|
||||
|
||||
namespace SheetMe.Designer.DataBusiness;
|
||||
|
||||
/// <summary>
|
||||
/// 서식 디자인 데이터 비즈니스 — 저장소(파일/DB) 접근을 ViewModel 에서 격리.
|
||||
/// 본 도구는 내부 관리용 팻 클라이언트로 저장소를 직접 호출한다(컨벤션 편차 — 계획서 명시).
|
||||
/// DB 저장은 SaveMode='Db' 설정 게이트 뒤에서만 활성화. REST 전환 시 이 계층만 교체한다.
|
||||
/// </summary>
|
||||
internal sealed class FormDesignDataBusiness
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly XmlFileFormStore fileStore = new();
|
||||
private OracleLegacyFormStore? dbStore;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>데이터 설정(appsettings.json)</summary>
|
||||
public DataConfig Config { get; } = ConfigLoader.Load();
|
||||
|
||||
/// <summary>DB 사용 가능 여부 — 접속 문자열 존재</summary>
|
||||
public bool CanUseDb => Config.ConnectionString.Length > 0;
|
||||
|
||||
/// <summary>DB 저장 허용 여부 — SaveMode='Db' 명시 설정</summary>
|
||||
public bool CanSaveToDb => CanUseDb && Config.IsDbSaveMode();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>레거시 XML 파일 열기</summary>
|
||||
public FormDocument OpenXmlFile(string path) => fileStore.LoadFile(path);
|
||||
|
||||
/// <summary>레거시 XML 파일 저장</summary>
|
||||
public void SaveXmlFile(FormDocument document, string path) => fileStore.SaveFile(document, path);
|
||||
|
||||
/// <summary>새 문서 생성 — 빈 페이지 1장(레거시 기본 720×856)</summary>
|
||||
public FormDocument CreateNew()
|
||||
{
|
||||
var document = new FormDocument
|
||||
{
|
||||
FormId = "NewSheet",
|
||||
Title = "새 서식",
|
||||
};
|
||||
document.Pages.Add(Core.Serialization.LegacyXmlSerializer.CreateEmptyPage(1));
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>DB 서식 목록 검색</summary>
|
||||
public List<SheetSummary> ListSheets(string? keyword)
|
||||
=> DbStore().ListSheets(keyword);
|
||||
|
||||
/// <summary>DB 활성 디자인 열기 — 디자인 없으면 null</summary>
|
||||
public FormDocument? OpenFromDb(string shtCod)
|
||||
=> DbStore().LoadActiveDesign(shtCod);
|
||||
|
||||
/// <summary>서식 디자인 버전 이력(E_SdgMst, 본문 제외)</summary>
|
||||
public List<DesignVersionInfo> ListVersions(string shtCod)
|
||||
=> DbStore().ListVersions(shtCod);
|
||||
|
||||
/// <summary>특정 버전 디자인 열기 — 이력 열람/복원</summary>
|
||||
public FormDocument? OpenFromDbVersion(string shtCod, decimal sdgKey)
|
||||
=> DbStore().LoadDesignByKey(shtCod, sdgKey);
|
||||
|
||||
/// <summary>서식의 Spread 격자(E_SpdMst 파싱) — 컨트롤명 → 격자 정보(파싱 실패 건 제외)</summary>
|
||||
public Dictionary<string, Core.Serialization.SpreadGridInfo> LoadSpreadGrids(string shtCod)
|
||||
{
|
||||
var result = new Dictionary<string, Core.Serialization.SpreadGridInfo>(StringComparer.Ordinal);
|
||||
foreach (var (name, xml) in DbStore().LoadSpreadDesigns(shtCod))
|
||||
{
|
||||
if (Core.Serialization.FarPointSpreadParser.Parse(xml) is { } grid)
|
||||
{
|
||||
result[name] = grid;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>DB 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — 반환 SdgKey</summary>
|
||||
public decimal SaveToDb(FormDocument document)
|
||||
{
|
||||
if (!CanSaveToDb)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요.");
|
||||
}
|
||||
return DbStore().SaveDesign(document, Environment.UserName);
|
||||
}
|
||||
|
||||
/// <summary>서식 코드 등록 여부(E_ShtMst)</summary>
|
||||
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
|
||||
|
||||
/// <summary>신규 서식 최소 등록 — 디자이너 서식 관행 기본값</summary>
|
||||
public void RegisterSheet(string shtCod, string korName, string? clsCod)
|
||||
=> DbStore().RegisterSheet(shtCod, korName, clsCod, Environment.UserName);
|
||||
|
||||
/// <summary>상용구 저장소(E_SHTWRDMST) — DB 접속 필요</summary>
|
||||
public RecordWordStore RecordWords()
|
||||
{
|
||||
if (!CanUseDb)
|
||||
{
|
||||
throw new InvalidOperationException("DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.");
|
||||
}
|
||||
return new RecordWordStore(Config.ConnectionString);
|
||||
}
|
||||
|
||||
private OracleLegacyFormStore DbStore()
|
||||
{
|
||||
if (!CanUseDb)
|
||||
{
|
||||
throw new InvalidOperationException("DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.");
|
||||
}
|
||||
return dbStore ??= new OracleLegacyFormStore(Config.ConnectionString);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
using System.IO;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Data.Stores;
|
||||
using SheetMe.Designer.Services;
|
||||
|
||||
namespace SheetMe.Designer.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// 실DB 검증 진단.
|
||||
/// --db-smoke <리포트> [N] : 읽기 전용 — 활성 디자인 N건 일괄 Read→Write 의미론 diff 통계.
|
||||
/// --db-save-smoke <서식코드> <리포트> : 쓰기 — 무변경 재저장 후 재로드 비교 + E_SctMst 행 수 검증.
|
||||
/// </summary>
|
||||
public static class DbSmoke
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>읽기 전용 일괄 왕복 스모크</summary>
|
||||
public static int RunReadSmoke(string reportPath, int maxSheets)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
var store = new OracleLegacyFormStore(config.ConnectionString);
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
|
||||
var sheets = store.ListSheets(null, max: 3000);
|
||||
lines.Add($"E_ShtMst 서식 {sheets.Count}건, 디자인 보유 {sheets.Count(s => s.HasDesign)}건");
|
||||
|
||||
var targets = sheets.Where(s => s.HasDesign).Take(maxSheets).ToList();
|
||||
var perfect = 0;
|
||||
var withWarnings = 0;
|
||||
var diffSheets = new List<string>();
|
||||
var failed = new List<string>();
|
||||
var placeholderTypes = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var sheet in targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
|
||||
if (raw is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var document = serializer.Read(raw.Value.Xml);
|
||||
var rewritten = serializer.Write(document);
|
||||
var diffs = XmlSemanticDiff.Compare(raw.Value.Xml, rewritten, maxDiffs: 5);
|
||||
|
||||
foreach (var warning in document.Meta.ReadWarnings.Where(w => w.StartsWith("미지원")))
|
||||
{
|
||||
var type = warning[(warning.IndexOf('(') + 1)..].TrimEnd(')');
|
||||
placeholderTypes[type] = placeholderTypes.GetValueOrDefault(type) + 1;
|
||||
}
|
||||
|
||||
if (diffs.Count == 0)
|
||||
{
|
||||
perfect++;
|
||||
if (document.Meta.ReadWarnings.Count > 0)
|
||||
{
|
||||
withWarnings++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
diffSheets.Add($" {sheet.ShtCod} {sheet.Name}: {diffs[0]}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed.Add($" {sheet.ShtCod} {sheet.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
lines.Add($"왕복 검사 {targets.Count}건 → 의미론 diff 0: {perfect}건 (미지원 컨트롤 보존 포함 {withWarnings}건), diff 발생: {diffSheets.Count}건, 예외: {failed.Count}건");
|
||||
if (placeholderTypes.Count > 0)
|
||||
{
|
||||
lines.Add("미지원(자리표시 보존) 타입 분포: " + string.Join(", ",
|
||||
placeholderTypes.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Key}×{kv.Value}")));
|
||||
}
|
||||
if (diffSheets.Count > 0)
|
||||
{
|
||||
lines.Add("[diff 발생 서식]");
|
||||
lines.AddRange(diffSheets.Take(20));
|
||||
}
|
||||
if (failed.Count > 0)
|
||||
{
|
||||
lines.Add("[예외 서식]");
|
||||
lines.AddRange(failed.Take(20));
|
||||
}
|
||||
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return diffSheets.Count == 0 && failed.Count == 0 ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lines.Add("실패: " + ex);
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
|
||||
public static int RunColumns(string tableName, string reportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText =
|
||||
"SELECT COLUMN_NAME, DATA_TYPE, NVL(DATA_LENGTH,0), NULLABLE " +
|
||||
"FROM USER_TAB_COLUMNS WHERE TABLE_NAME = UPPER(:t) ORDER BY COLUMN_ID";
|
||||
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("t", tableName));
|
||||
var lines = new List<string>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
lines.Add($"{reader.GetString(0),-16} {reader.GetString(1),-10} {reader.GetDecimal(2),5} " +
|
||||
$"{(reader.GetString(3) == "N" ? "NOT NULL" : "")}");
|
||||
}
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서식 마스터 행 주요 플래그 조회(--db-row) — 신규 등록 기본값 참조용(읽기 전용)</summary>
|
||||
public static int RunRow(string shtCod, string reportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText =
|
||||
"SELECT NVL(ShtTyp,'-'), NVL(ShtUseYon,'-'), NVL(ShtUsrDesYon,'-'), NVL(ShtUseSdg,'-'), NVL(ShtUseSct,'-'), " +
|
||||
"NVL(ShtUseEmr,'-'), NVL(ShtEleSig,'-'), NVL(ShtEdtAth,'-'), NVL(ShtRedAth,'-'), NVL(ShtWrtTyp,'-'), " +
|
||||
"NVL(ShtEdtTyp,'-'), NVL(ShtViwTyp,'-'), NVL(ShtClsCod,'-'), NVL(ShtCneYon,'-'), NVL(ShtTraSto,'-') " +
|
||||
"FROM E_ShtMst WHERE ShtCod = :s";
|
||||
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCod));
|
||||
using var reader = command.ExecuteReader();
|
||||
if (!reader.Read())
|
||||
{
|
||||
File.WriteAllText(reportPath, "행 없음");
|
||||
return 1;
|
||||
}
|
||||
var names = new[] { "ShtTyp", "ShtUseYon", "ShtUsrDesYon", "ShtUseSdg", "ShtUseSct", "ShtUseEmr",
|
||||
"ShtEleSig", "ShtEdtAth", "ShtRedAth", "ShtWrtTyp", "ShtEdtTyp", "ShtViwTyp", "ShtClsCod", "ShtCneYon", "ShtTraSto" };
|
||||
var lines = names.Select((n, i) => $"{n}={reader.GetString(i)}");
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용</summary>
|
||||
public static int RunSample(string tableName, string reportPath, int max = 12)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(tableName, "^[A-Za-z0-9_]{1,30}$"))
|
||||
{
|
||||
throw new ArgumentException("유효하지 않은 테이블명");
|
||||
}
|
||||
var config = ConfigLoader.Load();
|
||||
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = $"SELECT * FROM {tableName} WHERE ROWNUM <= {max}";
|
||||
var lines = new List<string>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var parts = new List<string>();
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
var value = reader.IsDBNull(i) ? "∅" : reader.GetValue(i).ToString() ?? "";
|
||||
if (value.Length > 30)
|
||||
{
|
||||
value = value[..30] + "…";
|
||||
}
|
||||
parts.Add($"{reader.GetName(i)}={value}");
|
||||
}
|
||||
lines.Add(string.Join(" | ", parts));
|
||||
}
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Spread 디자인 조회(--db-spd) — 목록 또는 특정 건 원문 덤프(읽기 전용)</summary>
|
||||
public static int RunSpreadDump(string shtCodOrAll, string reportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
if (shtCodOrAll == "*")
|
||||
{
|
||||
command.CommandText =
|
||||
"SELECT SpdShtCod, SpdName, LENGTH(SpdDesign), NVL(SpdDelYon,' ') FROM E_SpdMst ORDER BY SpdShtCod";
|
||||
var lines = new List<string>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
lines.Add($"{reader.GetString(0)}\t{reader.GetString(1)}\t{reader.GetDecimal(2):N0}자\t{(reader.GetString(3) == "Y" ? "이력" : "활성")}");
|
||||
}
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
}
|
||||
else
|
||||
{
|
||||
command.CommandText =
|
||||
"SELECT SpdDesign FROM E_SpdMst WHERE TRIM(SpdShtCod) = :s AND NVL(SpdDelYon,' ') <> 'Y' AND ROWNUM = 1";
|
||||
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCodOrAll));
|
||||
File.WriteAllText(reportPath, command.ExecuteScalar() as string ?? "(없음)");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>활성 디자인 XML 원문 덤프(--db-xml) — 읽기 전용</summary>
|
||||
public static int RunDesignXml(string shtCod, string reportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
var store = new OracleLegacyFormStore(config.ConnectionString);
|
||||
var raw = store.LoadActiveDesignRaw(shtCod);
|
||||
File.WriteAllText(reportPath,
|
||||
raw is null ? "(없음)" : $"SdgKey={raw.Value.SdgKey}{Environment.NewLine}{raw.Value.Xml}");
|
||||
return raw is null ? 1 : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서식 검색(--db-find) — 키워드 목록 출력</summary>
|
||||
public static int RunFind(string keyword, string reportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
var store = new OracleLegacyFormStore(config.ConnectionString);
|
||||
var sheets = store.ListSheets(keyword, max: 50);
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine,
|
||||
sheets.Select(s => $"{s.ShtCod}\t{(s.HasDesign ? "디자인O" : "-")}\t{s.Name}")));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>상용구(E_SHTWRDMST) CRUD 왕복 스모크(--db-word-smoke) — 등록→조회→수정→순서→삭제(흔적 없음)</summary>
|
||||
public static int RunWordSmoke(string shtCod, string reportPath)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
var store = new RecordWordStore(config.ConnectionString);
|
||||
|
||||
// 이전 스모크 잔여 정리
|
||||
foreach (var stale in store.List(shtCod).Where(w => w.Value.StartsWith("스모크 문구")))
|
||||
{
|
||||
store.Remove(stale.Key);
|
||||
}
|
||||
|
||||
var before = store.List(shtCod).Count;
|
||||
lines.Add($"[사전] {shtCod} 상용구 {before}건");
|
||||
|
||||
var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", Environment.UserName);
|
||||
var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", Environment.UserName);
|
||||
var afterAdd = store.List(shtCod);
|
||||
var okAdd = afterAdd.Count == before + 2
|
||||
&& afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2);
|
||||
lines.Add($"[추가] 2건 → {afterAdd.Count}건 (키 {key1}, {key2}) : {(okAdd ? "통과" : "실패")}");
|
||||
|
||||
store.Update(key1, "스모크 문구 1 (수정됨)", Environment.UserName);
|
||||
var updatedValue = store.List(shtCod).FirstOrDefault(w => w.Key == key1)?.Value;
|
||||
var okUpdate = updatedValue == "스모크 문구 1 (수정됨)";
|
||||
lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "통과" : "실패")}");
|
||||
|
||||
var reordered = afterAdd.Select(w => w.Key).ToList();
|
||||
reordered.Reverse();
|
||||
store.Reorder(reordered, Environment.UserName);
|
||||
var firstKey = store.List(shtCod).FirstOrDefault()?.Key;
|
||||
var okReorder = firstKey == reordered[0];
|
||||
lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "통과" : "실패")}");
|
||||
|
||||
store.Remove(key1);
|
||||
store.Remove(key2);
|
||||
var afterRemove = store.List(shtCod).Count;
|
||||
var okRemove = afterRemove == before;
|
||||
lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "통과" : "실패")}");
|
||||
|
||||
var ok = okAdd && okUpdate && okReorder && okRemove;
|
||||
lines.Add(ok ? "결과: 통과" : "결과: 실패");
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lines.Add("실패: " + ex);
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>무변경 재저장 스모크 — 저장 경로 전체(트랜잭션/버저닝/E_SctMst) 검증</summary>
|
||||
public static int RunSaveSmoke(string shtCod, string reportPath)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
var store = new OracleLegacyFormStore(config.ConnectionString);
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
|
||||
var before = store.LoadActiveDesignRaw(shtCod)
|
||||
?? throw new InvalidOperationException($"활성 디자인이 없습니다: {shtCod}");
|
||||
var cneYon = store.GetShtCneYon(shtCod);
|
||||
var versionsBefore = store.ListVersions(shtCod);
|
||||
lines.Add($"[사전] {shtCod} ShtCneYon='{cneYon}' 활성 SdgKey={before.SdgKey}, 버전 {versionsBefore.Count}건, XML {before.Xml.Length:N0}자");
|
||||
|
||||
// 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신)
|
||||
var document = serializer.Read(before.Xml);
|
||||
document.FormId = shtCod;
|
||||
var savedKey = store.SaveDesign(document, Environment.UserName);
|
||||
|
||||
var after = store.LoadActiveDesignRaw(shtCod)
|
||||
?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패");
|
||||
var versionsAfter = store.ListVersions(shtCod);
|
||||
lines.Add($"[저장] SdgKey {before.SdgKey} → {savedKey} ({(cneYon == "Y" ? "제자리 갱신" : "신규 버전")}), 버전 {versionsAfter.Count}건");
|
||||
|
||||
var diffs = XmlSemanticDiff.Compare(before.Xml, after.Xml, maxDiffs: 10);
|
||||
lines.Add($"[비교] 원본 vs 재저장 활성본 의미론 diff: {diffs.Count}건");
|
||||
lines.AddRange(diffs.Select(d => " " + d));
|
||||
|
||||
var sctCount = store.CountSctRows(savedKey);
|
||||
var walkerCount = SctMstXmlWalker.Walk(after.Xml).Count;
|
||||
lines.Add($"[E_SctMst] 저장본 행 {sctCount}건 / 워커 기대 {walkerCount}건 → {(sctCount == walkerCount ? "일치" : "불일치")}");
|
||||
|
||||
if (cneYon != "Y")
|
||||
{
|
||||
var oldVersion = versionsAfter.FirstOrDefault(v => v.SdgKey == before.SdgKey);
|
||||
lines.Add($"[이력] 이전 버전 SdgKey={before.SdgKey} SdgDelYon='Y' 처리: {(oldVersion?.Deleted == true ? "확인" : "미확인!")}");
|
||||
}
|
||||
|
||||
var ok = diffs.Count == 0 && sctCount == walkerCount;
|
||||
lines.Add(ok ? "결과: 통과 — 레거시 디자이너/EMR 미리보기로 열어 최종 육안 확인 권장" : "결과: 실패");
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lines.Add("실패: " + ex);
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Designer.DataBusiness;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// 편집 코어 자동 검증(--edit-smoke) — 실제 마우스 없이 InteractionController 에 포인터 시퀀스를 주입해
|
||||
/// 선택/이동/리사이즈/마퀴/복사/붙여넣기/삭제/Undo/저장왕복을 검사한다.
|
||||
/// </summary>
|
||||
public static class EditSmoke
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>스모크 실행 — 결과를 리포트 파일에 기록, 실패 있으면 1 반환</summary>
|
||||
public static int Run(string reportPath)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var failed = 0;
|
||||
|
||||
void Check(string name, bool condition, string? detail = null)
|
||||
{
|
||||
if (!condition)
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
lines.Add($"{(condition ? "PASS" : "FAIL")} {name}{(condition || detail is null ? "" : " — " + detail)}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var business = new FormDesignDataBusiness();
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
var designer = new DesignerViewModel(business.CreateNew());
|
||||
var none = new PointerContext(false, false, false, 1);
|
||||
var alt = new PointerContext(false, false, true, 1);
|
||||
var ctrl = new PointerContext(true, false, false, 1);
|
||||
|
||||
ControlViewModel Find(string type)
|
||||
=> designer.Pages[0].Controls.First(c => c.Type == type);
|
||||
|
||||
// 1) 팔레트 배치
|
||||
designer.AddControlAt("TextBox", new Point(100, 100));
|
||||
designer.AddControlAt("Label", new Point(400, 60));
|
||||
designer.AddControlAt("CheckBox", new Point(200, 260));
|
||||
Check("배치: VM/모델 3개", designer.Pages[0].Controls.Count == 3
|
||||
&& designer.Document.Pages[0].Controls.Count == 3);
|
||||
Check("배치: 신규가 선택됨", designer.Selection.Items.Count == 1);
|
||||
|
||||
// 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖)
|
||||
var textBox = Find("TextBox");
|
||||
var clickPoint = new Point(textBox.X + textBox.Width / 2, textBox.Y + textBox.Height / 2);
|
||||
designer.Interaction.PointerDown(clickPoint, none);
|
||||
designer.Interaction.PointerUp(clickPoint, none);
|
||||
Check("클릭 선택: Primary=TextBox", designer.Selection.Primary == textBox);
|
||||
|
||||
// 3) 드래그 이동(Alt=자유 이동 — 결정적 검증)
|
||||
var origX = textBox.X;
|
||||
var origY = textBox.Y;
|
||||
designer.Interaction.PointerDown(clickPoint, none);
|
||||
designer.Interaction.PointerMove(new Point(clickPoint.X + 33, clickPoint.Y + 47), alt);
|
||||
designer.Interaction.PointerUp(new Point(clickPoint.X + 33, clickPoint.Y + 47), alt);
|
||||
Check("드래그 이동 +33,+47", textBox.X == origX + 33 && textBox.Y == origY + 47,
|
||||
$"실제 ({textBox.X},{textBox.Y}) 기대 ({origX + 33},{origY + 47})");
|
||||
|
||||
// 4) Undo → 위치 원복(VM 재생성되므로 재조회)
|
||||
designer.Undo.Undo();
|
||||
var textBoxAfterUndo = Find("TextBox");
|
||||
Check("Undo: 위치 원복", textBoxAfterUndo.X == origX && textBoxAfterUndo.Y == origY,
|
||||
$"실제 ({textBoxAfterUndo.X},{textBoxAfterUndo.Y})");
|
||||
|
||||
// 5) Redo → 이동 재적용
|
||||
designer.Undo.Redo();
|
||||
var textBoxAfterRedo = Find("TextBox");
|
||||
Check("Redo: 이동 재적용", textBoxAfterRedo.X == origX + 33 && textBoxAfterRedo.Y == origY + 47);
|
||||
|
||||
// 6) 리사이즈 — SE 핸들(4번) 드래그
|
||||
var target = Find("TextBox");
|
||||
designer.Selection.SetSingle(target);
|
||||
var bbox = designer.SelectionWorldBounds()!.Value;
|
||||
var seHandle = new Point(bbox.Right, bbox.Bottom);
|
||||
var origW = target.Width;
|
||||
var origH = target.Height;
|
||||
designer.Interaction.PointerDown(seHandle, none);
|
||||
designer.Interaction.PointerMove(new Point(seHandle.X + 40, seHandle.Y + 18), alt);
|
||||
designer.Interaction.PointerUp(new Point(seHandle.X + 40, seHandle.Y + 18), alt);
|
||||
Check("리사이즈 +40,+18", target.Width == origW + 40 && target.Height == origH + 18,
|
||||
$"실제 ({target.Width},{target.Height}) 기대 ({origW + 40},{origH + 18})");
|
||||
|
||||
// 7) 마퀴 다중선택 — 전체를 덮는 사각형
|
||||
designer.Selection.Clear();
|
||||
designer.Interaction.PointerDown(new Point(1, 1), none);
|
||||
designer.Interaction.PointerMove(new Point(700, 700), none);
|
||||
designer.Interaction.PointerUp(new Point(700, 700), none);
|
||||
Check("마퀴: 3개 선택", designer.Selection.Items.Count == 3,
|
||||
$"실제 {designer.Selection.Items.Count}");
|
||||
|
||||
// 8) Ctrl+클릭 토글 해제
|
||||
var label = Find("Label");
|
||||
var labelPoint = new Point(label.X + 3, label.Y + 3);
|
||||
designer.Interaction.PointerDown(labelPoint, ctrl);
|
||||
designer.Interaction.PointerUp(labelPoint, ctrl);
|
||||
Check("Ctrl+클릭: 토글 해제 → 2개", designer.Selection.Items.Count == 2);
|
||||
|
||||
// 9) 복사/붙여넣기
|
||||
designer.Selection.SetSingle(Find("CheckBox"));
|
||||
designer.CopySelection();
|
||||
designer.Paste();
|
||||
Check("붙여넣기: 4개 + 새 Id", designer.Pages[0].Controls.Count == 4
|
||||
&& designer.Pages[0].Controls.Count(c => c.Type == "CheckBox") == 2
|
||||
&& designer.Pages[0].Controls.Where(c => c.Type == "CheckBox").Select(c => c.Id).Distinct().Count() == 2);
|
||||
|
||||
// 10) 삭제
|
||||
designer.DeleteSelection();
|
||||
Check("삭제: 3개", designer.Pages[0].Controls.Count == 3);
|
||||
|
||||
// 11) z-order — TextBox 맨 뒤로 (모델 마지막 = VM 첫번째)
|
||||
designer.Selection.SetSingle(Find("TextBox"));
|
||||
designer.ReorderSelection(toFront: false);
|
||||
Check("맨 뒤로: 모델 끝/VM 처음",
|
||||
designer.Document.Pages[0].Controls[^1].Type == "TextBox"
|
||||
&& designer.Pages[0].Controls[0].Type == "TextBox");
|
||||
|
||||
// 12) 넛지
|
||||
var nudgeTarget = Find("Label");
|
||||
designer.Selection.SetSingle(nudgeTarget);
|
||||
var beforeNudgeX = nudgeTarget.X;
|
||||
designer.NudgeSelection(1, 0);
|
||||
designer.NudgeSelection(1, 0);
|
||||
Check("넛지 +2", nudgeTarget.X == beforeNudgeX + 2);
|
||||
|
||||
// 13) 저장 → 재로드 → 재직렬화 동일(왕복 안정)
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), "sheetme_editsmoke.xml");
|
||||
business.SaveXmlFile(designer.Document, tempPath);
|
||||
var reloaded = business.OpenXmlFile(tempPath);
|
||||
var xmlA = serializer.Write(designer.Document);
|
||||
var xmlB = serializer.Write(reloaded);
|
||||
Check("저장/재로드 왕복 동일", xmlA == xmlB);
|
||||
|
||||
// 14) 신규 컨트롤 XML 에 레거시 필수 요소 포함
|
||||
Check("XML: AQN/Location/Name 포함",
|
||||
xmlA.Contains("M.EMR.UserControl.TextBox,") && xmlA.Contains("<Property name=\"Location\">")
|
||||
&& xmlA.Contains("<Property name=\"Name\">"));
|
||||
|
||||
// 15) 인스펙터 — 선택 시 행 구성 + Text 커밋
|
||||
var inspectorTarget = Find("Label");
|
||||
designer.Selection.SetSingle(inspectorTarget);
|
||||
Check("인스펙터: 행 생성", designer.Inspector.Rows.Count > 5,
|
||||
$"행 {designer.Inspector.Rows.Count}");
|
||||
var textRow = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.MultilineTextRowViewModel>()
|
||||
.FirstOrDefault(r => r.Label == "텍스트");
|
||||
Check("인스펙터: 텍스트 행 존재", textRow is not null);
|
||||
if (textRow is not null)
|
||||
{
|
||||
textRow.ValueText = "수정된 라벨";
|
||||
Check("인스펙터: Text 커밋 → 모델 반영",
|
||||
inspectorTarget.Model.Props.GetText("Text") == "수정된 라벨"
|
||||
&& inspectorTarget.Text == "수정된 라벨");
|
||||
Check("인스펙터: 커밋이 Undo 스텝", designer.Undo.CanUndo);
|
||||
}
|
||||
|
||||
// 16) 인스펙터 — 이름 변경
|
||||
var nameRow = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.TextRowViewModel>()
|
||||
.FirstOrDefault(r => r.Label == "이름");
|
||||
if (nameRow is not null)
|
||||
{
|
||||
nameRow.ValueText = "lblTitle";
|
||||
Check("인스펙터: 개명 반영", inspectorTarget.Id == "lblTitle");
|
||||
}
|
||||
|
||||
// 17) 페이지 추가/삭제 + 용지 크기
|
||||
designer.AddPage();
|
||||
Check("페이지 추가: 2페이지", designer.Pages.Count == 2
|
||||
&& designer.Document.Pages.Count == 2);
|
||||
designer.SelectedPage = designer.Pages[1];
|
||||
designer.ApplyPaperSize(794, 1123);
|
||||
Check("용지 크기 변경(A4)", designer.Pages[1].WidthDip == 794 && designer.Pages[1].HeightDip == 1123);
|
||||
designer.RemoveSelectedPageForSmoke();
|
||||
Check("페이지 삭제: 1페이지", designer.Pages.Count == 1);
|
||||
|
||||
// 18) 컨테이너 드롭 — Panel 배치 후 내부에 TextBox 드롭
|
||||
designer.AddControlAt("Panel", new Point(400, 400));
|
||||
var panel = designer.Pages[0].Controls.OfType<PanelViewModel>().First();
|
||||
designer.AddControlAt("TextBox", new Point(panel.X + 30, panel.Y + 30));
|
||||
Check("컨테이너 드롭: 자식 추가", panel.Model.Children.Count == 1
|
||||
&& panel.Children.Count == 1
|
||||
&& panel.Model.Children[0].Type == "TextBox");
|
||||
|
||||
// 19) 컨테이너 자식 직접 편집 — 히트테스트/이동/삭제
|
||||
var panelVm = designer.Pages[0].Controls.OfType<PanelViewModel>().First();
|
||||
var child = panelVm.Children[0];
|
||||
var childWorld = designer.WorldBoundsOf(child);
|
||||
var childHit = designer.HitTestControl(new Point(childWorld.X + childWorld.Width / 2, childWorld.Y + childWorld.Height / 2));
|
||||
Check("자식 히트테스트", childHit == child, $"실제 {childHit?.Id}");
|
||||
|
||||
designer.Selection.SetSingle(child);
|
||||
var childOrigX = child.X;
|
||||
var center = new Point(childWorld.X + childWorld.Width / 2, childWorld.Y + childWorld.Height / 2);
|
||||
designer.Interaction.PointerDown(center, none);
|
||||
designer.Interaction.PointerMove(new Point(center.X + 15, center.Y + 10), alt);
|
||||
designer.Interaction.PointerUp(new Point(center.X + 15, center.Y + 10), alt);
|
||||
Check("자식 이동(+15,+10 상대좌표)", child.X == childOrigX + 15,
|
||||
$"실제 X={child.X} 기대 {childOrigX + 15}");
|
||||
|
||||
designer.Selection.SetSingle(child);
|
||||
designer.DeleteSelection();
|
||||
Check("자식 삭제", panelVm.Children.Count == 0 && panelVm.Model.Children.Count == 0);
|
||||
|
||||
// 20) 그룹/해제 — 그룹 단위 클릭 선택
|
||||
designer.Selection.Set(new[] { Find("TextBox"), Find("Label") });
|
||||
designer.GroupSelection();
|
||||
designer.Selection.Clear();
|
||||
var member = Find("TextBox");
|
||||
var memberPoint = new Point(member.X + member.Width / 2, member.Y + member.Height / 2);
|
||||
designer.Interaction.PointerDown(memberPoint, none);
|
||||
designer.Interaction.PointerUp(memberPoint, none);
|
||||
Check("그룹 클릭 → 그룹 전체 선택", designer.Selection.Items.Count == 2,
|
||||
$"실제 {designer.Selection.Items.Count}");
|
||||
designer.UngroupSelection();
|
||||
designer.Selection.Clear();
|
||||
designer.Interaction.PointerDown(memberPoint, none);
|
||||
designer.Interaction.PointerUp(memberPoint, none);
|
||||
Check("그룹 해제 후 단일 선택", designer.Selection.Items.Count == 1);
|
||||
|
||||
// 20-1) 인라인 텍스트 편집 — 더블클릭 요청 이벤트 + 커밋
|
||||
var inlineTarget = Find("Label");
|
||||
ControlViewModel? inlineRequested = null;
|
||||
designer.InlineEditRequested += vm => inlineRequested = vm;
|
||||
var inlineWorld = designer.WorldBoundsOf(inlineTarget);
|
||||
var inlineCenter = new Point(inlineWorld.X + inlineWorld.Width / 2, inlineWorld.Y + inlineWorld.Height / 2);
|
||||
designer.Interaction.PointerDown(inlineCenter, new PointerContext(false, false, false, 2));
|
||||
designer.Interaction.PointerUp(inlineCenter, new PointerContext(false, false, false, 2));
|
||||
Check("더블클릭 → 인라인 편집 요청", inlineRequested == inlineTarget,
|
||||
$"실제 {inlineRequested?.Id ?? "null"}");
|
||||
designer.CommitInlineText(inlineTarget, "인라인 수정됨");
|
||||
Check("인라인 커밋 → 모델/Undo 반영",
|
||||
inlineTarget.Text == "인라인 수정됨"
|
||||
&& inlineTarget.Model.Props.GetText("Text") == "인라인 수정됨"
|
||||
&& designer.Undo.CanUndo);
|
||||
|
||||
// 20-2) 폰트 일괄 변경 — 집계 + 적용
|
||||
var usage = designer.CollectFontUsage();
|
||||
Check("폰트 집계: 조합 존재", usage.Count >= 2, $"실제 {usage.Count}");
|
||||
var applied = designer.ApplyFontBulk(usage, new Core.Serialization.LegacyFont
|
||||
{
|
||||
Family = "돋움",
|
||||
SizePt = 12,
|
||||
Bold = true,
|
||||
});
|
||||
Check("폰트 일괄 적용: 대상 수", applied >= 2, $"실제 {applied}");
|
||||
var fontTarget = Find("Label");
|
||||
Check("폰트 일괄 적용: 모델 반영",
|
||||
fontTarget.Model.Props.GetText("Font") == "돋움, 12pt, style=Bold"
|
||||
&& fontTarget.EffectiveFont.Family == "돋움" && fontTarget.EffectiveFont.Bold);
|
||||
designer.Undo.Undo();
|
||||
Check("폰트 일괄 적용: Undo 1스텝 원복",
|
||||
Find("Label").Model.Props.GetText("Font") != "돋움, 12pt, style=Bold");
|
||||
|
||||
// 20-3) 문서 간 복사/붙여넣기 — A 서식에서 복사 → B 서식에 붙여넣기(클립보드 공유)
|
||||
var designerB = new DesignerViewModel(business.CreateNew());
|
||||
designer.Selection.SetSingle(Find("TextBox"));
|
||||
designer.CopySelection();
|
||||
var beforePasteB = designerB.Pages[0].Controls.Count;
|
||||
designerB.Paste();
|
||||
Check("문서 간 붙여넣기: B 문서에 추가", designerB.Pages[0].Controls.Count == beforePasteB + 1
|
||||
&& designerB.Pages[0].Controls.Any(c => c.Type == "TextBox"),
|
||||
$"실제 {designerB.Pages[0].Controls.Count}");
|
||||
Check("문서 간 붙여넣기: A 문서 불변", designer.Pages[0].Controls.Count(c => c.Type == "TextBox") == 1);
|
||||
|
||||
// 20-4) 배치 명령 — 같은 크기/간격 균등/페이지 가운데/잘라내기
|
||||
designer.AddControlAt("Label", new Point(100, 500));
|
||||
designer.AddControlAt("Label", new Point(300, 520));
|
||||
designer.AddControlAt("Label", new Point(560, 540));
|
||||
var arrangeTargets = designer.Pages[0].Controls.Where(c => c.Y >= 480).ToList();
|
||||
designer.Selection.Set(arrangeTargets, arrangeTargets[0]);
|
||||
|
||||
designer.SizeToControl("Both");
|
||||
Check("같은 크기로: 기준에 통일",
|
||||
arrangeTargets.All(c => c.Width == arrangeTargets[0].Width && c.Height == arrangeTargets[0].Height));
|
||||
|
||||
designer.AdjustSpacing("HorizEqual");
|
||||
var sorted = arrangeTargets.OrderBy(c => c.X).ToList();
|
||||
var gap1 = sorted[1].X - (sorted[0].X + sorted[0].Width);
|
||||
var gap2 = sorted[2].X - (sorted[1].X + sorted[1].Width);
|
||||
Check("가로 간격 균등", Math.Abs(gap1 - gap2) <= 1, $"gap1={gap1} gap2={gap2}");
|
||||
|
||||
designer.AdjustSpacing("HorizConcat");
|
||||
sorted = arrangeTargets.OrderBy(c => c.X).ToList();
|
||||
Check("가로 붙이기: 간격 0", sorted[1].X == sorted[0].X + sorted[0].Width
|
||||
&& sorted[2].X == sorted[1].X + sorted[1].Width);
|
||||
|
||||
designer.CenterInPage("H");
|
||||
var bboxAfterCenter = designer.SelectionWorldBounds()!.Value;
|
||||
var pageW = designer.Pages[0].WidthDip;
|
||||
Check("페이지 가로 가운데", Math.Abs((bboxAfterCenter.X + bboxAfterCenter.Width / 2) - pageW / 2) <= 1,
|
||||
$"중심 {bboxAfterCenter.X + bboxAfterCenter.Width / 2} 기대 {pageW / 2}");
|
||||
|
||||
var beforeCut = designer.Pages[0].Controls.Count;
|
||||
designer.Selection.Set(new[] { arrangeTargets[0] }, arrangeTargets[0]);
|
||||
designer.CutSelection();
|
||||
Check("잘라내기: 삭제됨", designer.Pages[0].Controls.Count == beforeCut - 1);
|
||||
designer.Paste();
|
||||
Check("잘라내기 → 붙여넣기 복원", designer.Pages[0].Controls.Count == beforeCut);
|
||||
|
||||
// 남은 배치 테스트 컨트롤 정리
|
||||
designer.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 480).ToList());
|
||||
designer.DeleteSelection();
|
||||
|
||||
// 20-5) 탭순서 편집 — 모드 진입 → 역순 클릭 → 완료 적용(레거시 규약 0,100,200…)
|
||||
designer.AddControlAt("TextBox", new Point(100, 620));
|
||||
designer.AddControlAt("TextBox", new Point(300, 620));
|
||||
designer.AddControlAt("TextBox", new Point(500, 620));
|
||||
var tabTargets = designer.Pages[0].Controls
|
||||
.Where(c => c.Type == "TextBox" && c.Y >= 600)
|
||||
.OrderBy(c => c.X)
|
||||
.ToList();
|
||||
|
||||
designer.ToggleTabOrderMode(); // 시작
|
||||
Check("탭순서: 모드 진입 + 배지 생성", designer.IsTabOrderMode
|
||||
&& designer.Overlay.TabBadges.Count >= 3,
|
||||
$"배지 {designer.Overlay.TabBadges.Count}");
|
||||
|
||||
// 오른쪽→왼쪽 역순으로 클릭(입력 순서 지정)
|
||||
for (var i = tabTargets.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var world = designer.WorldBoundsOf(tabTargets[i]);
|
||||
designer.Interaction.PointerDown(new Point(world.X + world.Width / 2, world.Y + world.Height / 2), none);
|
||||
designer.Interaction.PointerUp(new Point(world.X + world.Width / 2, world.Y + world.Height / 2), none);
|
||||
}
|
||||
Check("탭순서: 배지 순번 표시", designer.Overlay.TabBadges.Count(b => b.IsAssigned) == 3);
|
||||
|
||||
designer.ToggleTabOrderMode(); // 완료(적용)
|
||||
Check("탭순서: 적용 — 클릭 순서 0/100/200", !designer.IsTabOrderMode
|
||||
&& tabTargets[2].Model.Props.GetText("TabIndex") == "0"
|
||||
&& tabTargets[1].Model.Props.GetText("TabIndex") == "100"
|
||||
&& tabTargets[0].Model.Props.GetText("TabIndex") == "200",
|
||||
$"실제 {tabTargets[2].Model.Props.GetText("TabIndex")}/{tabTargets[1].Model.Props.GetText("TabIndex")}/{tabTargets[0].Model.Props.GetText("TabIndex")}");
|
||||
Check("탭순서: 미클릭 컨트롤 뒤 순번(999999+)",
|
||||
Find("Label").Model.Props.GetText("TabIndex")?.StartsWith("1000") == true);
|
||||
designer.Undo.Undo();
|
||||
Check("탭순서: Undo 원복",
|
||||
designer.Pages[0].Controls.First(c => c.Type == "TextBox" && c.Y >= 600).Model.Props.GetText("TabIndex") is null or "0");
|
||||
|
||||
// 정리
|
||||
designer.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 600).ToList());
|
||||
designer.DeleteSelection();
|
||||
|
||||
// 20-6) 전체 속성 그리드(고급) — 토글 → raw 편집 → 속성 추가
|
||||
var advTarget = Find("TextBox");
|
||||
designer.Selection.SetSingle(advTarget);
|
||||
var toggleRow = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.ToggleAdvancedRowViewModel>().FirstOrDefault();
|
||||
Check("고급: 토글 행 존재", toggleRow is not null);
|
||||
toggleRow!.ToggleCommand!.Execute(null, EventArgs.Empty);
|
||||
var advRows = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.TextRowViewModel>()
|
||||
.Where(r => r.Label is "LocationOnBase" or "TabIndex" or "DisplaySequence")
|
||||
.ToList();
|
||||
Check("고급: raw 속성 행 노출", designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.SectionRowViewModel>()
|
||||
.Any(r => r.Label.StartsWith("전체 속성")));
|
||||
|
||||
var addRow = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.AddPropertyRowViewModel>().FirstOrDefault();
|
||||
Check("고급: 속성 추가 행 존재", addRow is not null);
|
||||
addRow!.KeyText = "EnterTabYon";
|
||||
addRow.AddCommand!.Execute(null, EventArgs.Empty);
|
||||
Check("고급: 속성 추가됨", advTarget.Model.Props.Contains("EnterTabYon"));
|
||||
var enterTabRow = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.TextRowViewModel>()
|
||||
.FirstOrDefault(r => r.Label == "EnterTabYon");
|
||||
Check("고급: 추가 속성 행 노출", enterTabRow is not null);
|
||||
enterTabRow!.ValueText = "True";
|
||||
Check("고급: raw 값 커밋", advTarget.Model.Props.GetText("EnterTabYon") == "True");
|
||||
|
||||
// 21) JSON 왕복(부 포맷) — 편집 결과 유지
|
||||
var jsonSerializer = new Core.Serialization.FormJsonSerializer();
|
||||
var json = jsonSerializer.Write(designer.Document);
|
||||
var fromJson = jsonSerializer.Read(json);
|
||||
Check("JSON 왕복 — XML 동일", serializer.Write(fromJson) == serializer.Write(designer.Document));
|
||||
|
||||
// 22) 연속 Undo 로 빈 문서까지
|
||||
var guard = 0;
|
||||
while (designer.Undo.CanUndo && guard++ < 80)
|
||||
{
|
||||
designer.Undo.Undo();
|
||||
}
|
||||
Check("전체 Undo → 빈 문서", designer.Document.Pages[0].Controls.Count == 0
|
||||
&& designer.Document.Pages.Count == 1,
|
||||
$"실제 컨트롤 {designer.Document.Pages[0].Controls.Count} 페이지 {designer.Document.Pages.Count}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
lines.Add("EXCEPTION " + ex);
|
||||
}
|
||||
|
||||
lines.Add($"결과: 실패 {failed}건");
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SheetMe.Data.Config;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// appsettings → DataConfig 바인딩 로더.
|
||||
/// 우선순위: 환경변수 > appsettings.Development.json > appsettings.json.
|
||||
/// 커밋되는 appsettings.json 은 <c>__HOST__</c> 류 플레이스홀더만 담으며, 실접속 정보는
|
||||
/// appsettings.Development.json(개발, Debug 빌드에서만 산출물 복사) 또는 환경변수로 주입한다.
|
||||
/// </summary>
|
||||
public static class ConfigLoader
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>설정 로드 — 설정이 없으면 기본값(File 모드)</summary>
|
||||
public static DataConfig Load()
|
||||
{
|
||||
var config = new DataConfig();
|
||||
var basePath = AppContext.BaseDirectory;
|
||||
|
||||
var root = new ConfigurationBuilder()
|
||||
.SetBasePath(basePath)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Development.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var connectionString = root.GetConnectionString("His") ?? string.Empty;
|
||||
config.ConnectionString = IsPlaceholder(connectionString) ? string.Empty : connectionString;
|
||||
config.Provider = root["His:Provider"] ?? "Oracle";
|
||||
config.SaveMode = root["FormStore:SaveMode"] ?? "File";
|
||||
config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms";
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 커밋본의 미치환 플레이스홀더인지 — 이 경우 '미설정'으로 간주해 DB 기능을 끈다.
|
||||
/// 플레이스홀더를 그대로 접속에 쓰면 무의미한 연결 실패 예외가 사용자에게 노출된다.
|
||||
/// </summary>
|
||||
private static bool IsPlaceholder(string connectionString) =>
|
||||
connectionString.Length == 0 || connectionString.Contains("__", StringComparison.Ordinal);
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>파일 대화상자 래퍼 — ViewModel 에서 View 기술 의존을 격리.</summary>
|
||||
public sealed class DialogService
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>열기 대화상자 — 취소 시 null</summary>
|
||||
public string? ShowOpenFile(string filter, string title)
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Filter = filter,
|
||||
Title = title,
|
||||
};
|
||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
/// <summary>저장 대화상자 — 취소 시 null</summary>
|
||||
public string? ShowSaveFile(string filter, string title, string defaultFileName)
|
||||
{
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Filter = filter,
|
||||
Title = title,
|
||||
FileName = defaultFileName,
|
||||
};
|
||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 모델 ↔ ViewModel 매핑 팩토리.
|
||||
/// z-order 규약: 모델(=XML Object 순서)은 index 0 이 최상위(WinForms Controls 규약),
|
||||
/// WPF Canvas 는 나중에 그린 것이 위 — 따라서 VM 컬렉션은 모델의 역순(그리기 순서)이다.
|
||||
/// </summary>
|
||||
public static class DocumentMapper
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>페이지 모델 → 페이지 VM (컨트롤 트리 포함)</summary>
|
||||
public static PageViewModel CreatePage(FormPage page, int index)
|
||||
{
|
||||
var pageViewModel = new PageViewModel(page, index);
|
||||
|
||||
// 루트 폰트/글자색이 상속의 기점 (실샘플: 루트 Font "굴림, 11.25pt")
|
||||
var rootFontText = page.Root.Props.GetText("Font");
|
||||
var rootFont = rootFontText is not null ? LegacyFormat.ParseFont(rootFontText) : new LegacyFont();
|
||||
var rootForeground = Brushes.Black;
|
||||
|
||||
foreach (var model in PaintOrder(page.Controls))
|
||||
{
|
||||
pageViewModel.Controls.Add(CreateControl(model, rootFont, rootForeground));
|
||||
}
|
||||
return pageViewModel;
|
||||
}
|
||||
|
||||
/// <summary>컨트롤 모델 → 타입별 VM (자식·시각 컨텍스트 포함)</summary>
|
||||
public static ControlViewModel CreateControl(ControlElement model, LegacyFont parentFont, Brush parentForeground)
|
||||
{
|
||||
ControlViewModel viewModel = model.Type switch
|
||||
{
|
||||
"Label" => new LabelViewModel(model),
|
||||
"TextBox" => new TextBoxViewModel(model),
|
||||
"MaskedTextBox" => new MaskedTextBoxViewModel(model),
|
||||
"CheckBox" => new CheckBoxViewModel(model),
|
||||
"RadioButton" => new RadioButtonViewModel(model),
|
||||
"ComboBox" => new ComboBoxViewModel(model),
|
||||
"ListBox" => new ListBoxViewModel(model),
|
||||
"CheckList" => new CheckListViewModel(model),
|
||||
"DateTimePicker" => new DateTimePickerViewModel(model),
|
||||
"Panel" => new PanelViewModel(model),
|
||||
"GroupBox" => new GroupBoxViewModel(model),
|
||||
"Line" => new LineViewModel(model),
|
||||
"PictureBox" => new PictureBoxViewModel(model),
|
||||
"CalcBox" => new CalcBoxViewModel(model),
|
||||
"Button" => new ButtonViewModel(model),
|
||||
"DataTable" => new DataTableViewModel(model),
|
||||
"Spread" => new SpreadViewModel(model),
|
||||
_ => new PlaceholderViewModel(model),
|
||||
};
|
||||
|
||||
viewModel.ResolveVisualContext(parentFont, parentForeground);
|
||||
|
||||
if (viewModel is ContainerViewModel container)
|
||||
{
|
||||
foreach (var child in PaintOrder(model.Children))
|
||||
{
|
||||
var childViewModel = CreateControl(child, viewModel.EffectiveFont, viewModel.Foreground);
|
||||
childViewModel.Parent = container;
|
||||
container.Children.Add(childViewModel);
|
||||
}
|
||||
}
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
/// <summary>모델 순서(0=최상위) → 그리기 순서(마지막=최상위)로 역전</summary>
|
||||
private static IEnumerable<ControlElement> PaintOrder(List<ControlElement> models)
|
||||
=> Enumerable.Reverse(models);
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>컨트롤 Id 생성 — 문서 전체에서 유일한 "TextBox1" 식 이름 부여(레거시 관행).</summary>
|
||||
public static class IdGenerator
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>문서의 사용 중 Id 집합 수집</summary>
|
||||
public static HashSet<string> CollectUsed(FormDocument document)
|
||||
{
|
||||
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var page in document.Pages)
|
||||
{
|
||||
Collect(page.Root, used);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
/// <summary>타입 기반 유일 Id 생성 — 발급한 Id 는 used 집합에 추가된다(연속 발급 안전)</summary>
|
||||
public static string NextId(HashSet<string> used, string type)
|
||||
{
|
||||
for (var i = 1; ; i++)
|
||||
{
|
||||
var candidate = type + i;
|
||||
if (used.Add(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>단건 발급 편의 — 문서 스캔 후 1개 생성</summary>
|
||||
public static string NextId(FormDocument document, string type)
|
||||
=> NextId(CollectUsed(document), type);
|
||||
|
||||
private static void Collect(ControlElement element, HashSet<string> used)
|
||||
{
|
||||
if (element.Id.Length > 0)
|
||||
{
|
||||
used.Add(element.Id);
|
||||
}
|
||||
foreach (var child in element.Children)
|
||||
{
|
||||
Collect(child, used);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>포인터 컨텍스트 — 수정키/클릭 수</summary>
|
||||
public readonly record struct PointerContext(bool Ctrl, bool Shift, bool Alt, int ClickCount);
|
||||
|
||||
/// <summary>커서 종류 — 뷰(Behavior)가 WPF Cursor 로 매핑</summary>
|
||||
public enum CursorKind
|
||||
{
|
||||
Arrow,
|
||||
SizeAll,
|
||||
SizeNWSE,
|
||||
SizeNESW,
|
||||
SizeNS,
|
||||
SizeWE,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 마우스 상호작용 상태머신 — 월드 좌표만 다루는 순수 로직(뷰 비의존).
|
||||
/// 상태: None → PendingMove(임계 대기) → Move / Resize / Marquee.
|
||||
/// 드래그 = Undo 1스텝(첫 실이동 시 스냅샷), 절대좌표 재계산(드리프트 방지).
|
||||
/// </summary>
|
||||
public sealed class InteractionController
|
||||
{
|
||||
#region Member Fields
|
||||
private enum Mode { None, PendingMove, Move, Resize, Marquee }
|
||||
|
||||
private const double DragThreshold = 2;
|
||||
private const double MinSize = 8;
|
||||
|
||||
private readonly DesignerViewModel designer;
|
||||
private Mode mode = Mode.None;
|
||||
private Point downPoint;
|
||||
private ControlViewModel? downControl;
|
||||
private bool toggleOnUp;
|
||||
private bool collapseOnUp;
|
||||
private int resizeHandleIndex = -1;
|
||||
private Rect groupBounds0;
|
||||
private readonly Dictionary<ControlViewModel, Rect> origBounds = new();
|
||||
private bool undoCaptured;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public InteractionController(DesignerViewModel designer)
|
||||
{
|
||||
this.designer = designer;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Pointer
|
||||
/// <summary>마우스 다운(월드 좌표)</summary>
|
||||
public void PointerDown(Point world, PointerContext ctx)
|
||||
{
|
||||
// 탭순서 편집 모드 — 클릭은 순번 지정으로만 동작(드래그/핸들/마퀴 차단)
|
||||
if (designer.IsTabOrderMode)
|
||||
{
|
||||
var tabHit = designer.HitTestControl(world);
|
||||
if (tabHit is not null)
|
||||
{
|
||||
designer.ToggleTabOrderClick(tabHit);
|
||||
}
|
||||
mode = Mode.None;
|
||||
return;
|
||||
}
|
||||
|
||||
toggleOnUp = false;
|
||||
collapseOnUp = false;
|
||||
undoCaptured = false;
|
||||
downPoint = world;
|
||||
|
||||
// 1) 핸들 히트 — 선택 bbox 의 8핸들
|
||||
var handleIndex = HandleAt(world);
|
||||
if (handleIndex >= 0)
|
||||
{
|
||||
mode = Mode.Resize;
|
||||
resizeHandleIndex = handleIndex;
|
||||
CaptureOrigBounds();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 컨트롤 히트 — 그룹 멤버면 그룹 전체 단위로 선택
|
||||
var hit = designer.HitTestControl(world);
|
||||
if (hit is not null)
|
||||
{
|
||||
// 더블클릭 → 인라인 텍스트 편집(드래그 시작 안 함)
|
||||
if (ctx.ClickCount >= 2 && DesignerViewModel.IsTextEditable(hit))
|
||||
{
|
||||
mode = Mode.None;
|
||||
designer.RequestInlineEdit(hit);
|
||||
return;
|
||||
}
|
||||
|
||||
downControl = hit;
|
||||
if (ctx.Ctrl || ctx.Shift)
|
||||
{
|
||||
toggleOnUp = true; // 드래그 없이 업이면 토글
|
||||
}
|
||||
else if (!hit.IsSelected)
|
||||
{
|
||||
designer.Selection.Set(designer.GroupMatesOf(hit), hit);
|
||||
}
|
||||
else if (designer.Selection.Items.Count > designer.GroupMatesOf(hit).Count)
|
||||
{
|
||||
collapseOnUp = true; // 그룹보다 넓은 다중선택에서 클릭만 하면 그룹/단일로 축소
|
||||
}
|
||||
mode = Mode.PendingMove;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 빈 곳 — 마퀴
|
||||
if (!ctx.Ctrl && !ctx.Shift)
|
||||
{
|
||||
designer.Selection.Clear();
|
||||
}
|
||||
mode = Mode.Marquee;
|
||||
designer.Overlay.UpdateMarquee(new Rect(world, world));
|
||||
}
|
||||
|
||||
/// <summary>마우스 이동(월드 좌표)</summary>
|
||||
public void PointerMove(Point world, PointerContext ctx)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case Mode.PendingMove:
|
||||
if (Distance(world, downPoint) > DragThreshold && downControl is not null)
|
||||
{
|
||||
if (downControl.Model.Locked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
mode = Mode.Move;
|
||||
toggleOnUp = false;
|
||||
collapseOnUp = false;
|
||||
BeginMove();
|
||||
DoMove(world, ctx); // 전환 프레임부터 즉시 추종
|
||||
}
|
||||
break;
|
||||
|
||||
case Mode.Move:
|
||||
DoMove(world, ctx);
|
||||
break;
|
||||
|
||||
case Mode.Resize:
|
||||
DoResize(world, ctx);
|
||||
break;
|
||||
|
||||
case Mode.Marquee:
|
||||
designer.Overlay.UpdateMarquee(RectFrom(downPoint, world));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>마우스 업(월드 좌표)</summary>
|
||||
public void PointerUp(Point world, PointerContext ctx)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case Mode.PendingMove:
|
||||
if (toggleOnUp && downControl is not null)
|
||||
{
|
||||
// 그룹 단위 토글
|
||||
var mates = designer.GroupMatesOf(downControl);
|
||||
if (downControl.IsSelected)
|
||||
{
|
||||
designer.Selection.Set(designer.Selection.Items.Except(mates).ToList());
|
||||
}
|
||||
else
|
||||
{
|
||||
designer.Selection.Set(designer.Selection.Items.Union(mates).ToList(), downControl);
|
||||
}
|
||||
}
|
||||
else if (collapseOnUp && downControl is not null)
|
||||
{
|
||||
designer.Selection.Set(designer.GroupMatesOf(downControl), downControl);
|
||||
}
|
||||
break;
|
||||
|
||||
case Mode.Move:
|
||||
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
|
||||
designer.Overlay.SetGuides(null, null);
|
||||
break;
|
||||
|
||||
case Mode.Resize:
|
||||
designer.Overlay.SetGuides(null, null);
|
||||
break;
|
||||
|
||||
case Mode.Marquee:
|
||||
var rect = RectFrom(downPoint, world);
|
||||
designer.Overlay.UpdateMarquee(null);
|
||||
var hits = designer.ControlsIntersecting(rect)
|
||||
.SelectMany(designer.GroupMatesOf)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (ctx.Ctrl || ctx.Shift)
|
||||
{
|
||||
var merged = designer.Selection.Items.Union(hits).ToList();
|
||||
designer.Selection.Set(merged);
|
||||
}
|
||||
else if (hits.Count > 0)
|
||||
{
|
||||
designer.Selection.Set(hits);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
mode = Mode.None;
|
||||
downControl = null;
|
||||
origBounds.Clear();
|
||||
designer.RefreshOverlay();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 진행 중 드래그만 취소(원위치 복원) — 캡처 상실 등 비정상 종료용.
|
||||
/// 유휴 상태에서는 아무것도 하지 않는다(정상 마우스 업 후 캡처 해제가 선택을 지우면 안 됨).
|
||||
/// </summary>
|
||||
public void CancelDrag()
|
||||
{
|
||||
if (mode is Mode.Move or Mode.Resize)
|
||||
{
|
||||
foreach (var (vm, rect) in origBounds)
|
||||
{
|
||||
var offset = designer.ParentWorldOffset(vm);
|
||||
vm.X = rect.X - offset.X;
|
||||
vm.Y = rect.Y - offset.Y;
|
||||
vm.Width = rect.Width;
|
||||
vm.Height = rect.Height;
|
||||
}
|
||||
designer.Overlay.SetGuides(null, null);
|
||||
mode = Mode.None;
|
||||
origBounds.Clear();
|
||||
designer.RefreshOverlay();
|
||||
}
|
||||
else if (mode is Mode.Marquee or Mode.PendingMove)
|
||||
{
|
||||
designer.Overlay.UpdateMarquee(null);
|
||||
mode = Mode.None;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Esc — 탭순서 모드 취소 / 진행 중 드래그 취소 / 유휴 상태면 선택 해제</summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (designer.IsTabOrderMode)
|
||||
{
|
||||
designer.CancelTabOrderMode();
|
||||
return;
|
||||
}
|
||||
if (mode == Mode.None)
|
||||
{
|
||||
designer.Selection.Clear();
|
||||
return;
|
||||
}
|
||||
CancelDrag();
|
||||
}
|
||||
|
||||
/// <summary>커서 판정(호버 피드백)</summary>
|
||||
public CursorKind CursorAt(Point world)
|
||||
{
|
||||
var handle = HandleAt(world);
|
||||
if (handle >= 0)
|
||||
{
|
||||
return handle switch
|
||||
{
|
||||
0 or 4 => CursorKind.SizeNWSE,
|
||||
2 or 6 => CursorKind.SizeNESW,
|
||||
1 or 5 => CursorKind.SizeNS,
|
||||
_ => CursorKind.SizeWE,
|
||||
};
|
||||
}
|
||||
if (mode == Mode.Move)
|
||||
{
|
||||
return CursorKind.SizeAll;
|
||||
}
|
||||
return designer.HitTestControl(world) is not null ? CursorKind.SizeAll : CursorKind.Arrow;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Private
|
||||
private void BeginMove()
|
||||
{
|
||||
CaptureOrigBounds();
|
||||
var page = designer.PageOf(designer.Selection.Primary!) ?? designer.Pages.FirstOrDefault();
|
||||
if (page is not null)
|
||||
{
|
||||
designer.Snap.BeginDrag(page, designer.Selection.Items.ToHashSet());
|
||||
}
|
||||
}
|
||||
|
||||
private void DoMove(Point world, PointerContext ctx)
|
||||
{
|
||||
EnsureUndoSnapshot();
|
||||
|
||||
var dx = world.X - downPoint.X;
|
||||
var dy = world.Y - downPoint.Y;
|
||||
|
||||
var snap = designer.Snap.SnapMove(groupBounds0, dx, dy, ctx.Alt);
|
||||
designer.Overlay.SetGuides(snap.GuideX, snap.GuideY);
|
||||
|
||||
// origBounds 는 월드 좌표 — 로컬 = (원본 월드 + 보정 델타) - 부모 체인 오프셋
|
||||
foreach (var (vm, rect) in origBounds)
|
||||
{
|
||||
var offset = designer.ParentWorldOffset(vm);
|
||||
vm.X = Math.Round(rect.X + snap.Dx - offset.X);
|
||||
vm.Y = Math.Round(rect.Y + snap.Dy - offset.Y);
|
||||
}
|
||||
|
||||
designer.Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
private void DoResize(Point world, PointerContext ctx)
|
||||
{
|
||||
EnsureUndoSnapshot();
|
||||
|
||||
var rect = groupBounds0;
|
||||
var left = rect.Left;
|
||||
var top = rect.Top;
|
||||
var right = rect.Right;
|
||||
var bottom = rect.Bottom;
|
||||
|
||||
// 핸들별 이동 모서리 (0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)
|
||||
if (resizeHandleIndex is 0 or 6 or 7)
|
||||
{
|
||||
left = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
|
||||
}
|
||||
if (resizeHandleIndex is 2 or 3 or 4)
|
||||
{
|
||||
right = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
|
||||
}
|
||||
if (resizeHandleIndex is 0 or 1 or 2)
|
||||
{
|
||||
top = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
|
||||
}
|
||||
if (resizeHandleIndex is 4 or 5 or 6)
|
||||
{
|
||||
bottom = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
|
||||
}
|
||||
|
||||
var newRect = new Rect(
|
||||
Math.Min(left, right - MinSize),
|
||||
Math.Min(top, bottom - MinSize),
|
||||
Math.Max(MinSize, right - left),
|
||||
Math.Max(MinSize, bottom - top));
|
||||
|
||||
// 그룹 비례 스케일 — origBounds 기준 절대 재계산
|
||||
var sx = newRect.Width / Math.Max(1, groupBounds0.Width);
|
||||
var sy = newRect.Height / Math.Max(1, groupBounds0.Height);
|
||||
|
||||
foreach (var (vm, orig) in origBounds)
|
||||
{
|
||||
var offset = designer.ParentWorldOffset(vm);
|
||||
vm.X = Math.Round(newRect.X + (orig.X - groupBounds0.X) * sx - offset.X);
|
||||
vm.Y = Math.Round(newRect.Y + (orig.Y - groupBounds0.Y) * sy - offset.Y);
|
||||
vm.Width = Math.Max(1, Math.Round(orig.Width * sx));
|
||||
vm.Height = Math.Max(1, Math.Round(orig.Height * sy));
|
||||
}
|
||||
|
||||
designer.Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
/// <summary>드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝</summary>
|
||||
private void EnsureUndoSnapshot()
|
||||
{
|
||||
if (!undoCaptured)
|
||||
{
|
||||
designer.Undo.Snapshot();
|
||||
undoCaptured = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>선택 항목의 월드 bbox·개별 원본 bounds 캡처(X 는 페이지 로컬 = 월드 X, Y 는 월드)</summary>
|
||||
private void CaptureOrigBounds()
|
||||
{
|
||||
origBounds.Clear();
|
||||
foreach (var vm in designer.Selection.Items)
|
||||
{
|
||||
origBounds[vm] = designer.WorldBoundsOf(vm);
|
||||
}
|
||||
groupBounds0 = designer.SelectionWorldBounds() ?? Rect.Empty;
|
||||
}
|
||||
|
||||
private int HandleAt(Point world)
|
||||
{
|
||||
var overlay = designer.Overlay;
|
||||
if (!overlay.HasSelection || !overlay.ShowHandles)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
var bbox = new Rect(overlay.SelX, overlay.SelY, overlay.SelW, overlay.SelH);
|
||||
var positions = SelectionOverlayViewModel.HandlePositions(bbox);
|
||||
|
||||
// 반경 내 '최근접' 핸들 선택 — 작은 컨트롤에서 인접 핸들 오인 방지
|
||||
const double hitRadius = 6;
|
||||
var best = -1;
|
||||
var bestDistance = double.MaxValue;
|
||||
for (var i = 0; i < positions.Length; i++)
|
||||
{
|
||||
var dx = Math.Abs(world.X - positions[i].X);
|
||||
var dy = Math.Abs(world.Y - positions[i].Y);
|
||||
if (dx > hitRadius || dy > hitRadius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var distance = dx * dx + dy * dy;
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static double Distance(Point a, Point b)
|
||||
=> Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
|
||||
|
||||
private static Rect RectFrom(Point a, Point b)
|
||||
=> new(Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y));
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 인쇄 서비스 — 페이지당 FixedPage 1:1(DIP)로 조립해 인쇄.
|
||||
/// 캔버스와 동일한 DataTemplate 사전(App 리소스)을 사용하므로 화면=인쇄 렌더가 일치한다.
|
||||
/// 용지 그림자 등 편집 크롬 없이 흰 배경 + 컨트롤만 그린다.
|
||||
/// </summary>
|
||||
public static class PrintService
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary>
|
||||
public static void Print(DesignerViewModel designer, string documentName)
|
||||
{
|
||||
var dialog = new System.Windows.Controls.PrintDialog();
|
||||
if (dialog.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var document = BuildFixedDocument(designer);
|
||||
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
|
||||
}
|
||||
|
||||
/// <summary>페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용)</summary>
|
||||
public static FixedDocument BuildFixedDocument(DesignerViewModel designer)
|
||||
{
|
||||
var document = new FixedDocument();
|
||||
foreach (var page in designer.Pages)
|
||||
{
|
||||
var fixedPage = new FixedPage
|
||||
{
|
||||
Width = page.WidthDip,
|
||||
Height = page.HeightDip,
|
||||
Background = page.PaperBrush,
|
||||
};
|
||||
fixedPage.Children.Add(BuildPageVisual(page));
|
||||
|
||||
var pageContent = new PageContent();
|
||||
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
|
||||
document.Pages.Add(pageContent);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>페이지 컨트롤층 비주얼 — 크롬 없는 Canvas(그리기 순서 = 컬렉션 순서, 미리보기/인쇄 공용)</summary>
|
||||
public static UIElement BuildPageVisual(PageViewModel page)
|
||||
{
|
||||
var canvas = new Canvas
|
||||
{
|
||||
Width = page.WidthDip,
|
||||
Height = page.HeightDip,
|
||||
};
|
||||
TextOptions.SetTextFormattingMode(canvas, TextFormattingMode.Ideal);
|
||||
|
||||
// 종이 위 렌더는 레거시 충실 유지 — 앱 테마의 암시 TextBlock 스타일(다크 밝은 글자) 차단
|
||||
var paperText = new Style(typeof(TextBlock));
|
||||
paperText.Setters.Add(new Setter(TextBlock.ForegroundProperty, Brushes.Black));
|
||||
canvas.Resources.Add(typeof(TextBlock), paperText);
|
||||
|
||||
foreach (var control in page.Controls)
|
||||
{
|
||||
// 숨김 + 데이터소스(MDataTable — 런타임 비가시)는 인쇄/미리보기에서 제외
|
||||
if (control.Model.Hidden || control is DataTableViewModel)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var presenter = new ContentPresenter
|
||||
{
|
||||
Content = control,
|
||||
Width = Math.Max(1, control.Width),
|
||||
Height = Math.Max(1, control.Height),
|
||||
};
|
||||
Canvas.SetLeft(presenter, control.X);
|
||||
Canvas.SetTop(presenter, control.Y);
|
||||
canvas.Children.Add(presenter);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>선택 집합 관리 — 다중선택 + Primary. 변경 시 IsSelected 플래그 동기화 및 Changed 통지.</summary>
|
||||
public sealed class SelectionService
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly List<ControlViewModel> items = new();
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택된 컨트롤 목록</summary>
|
||||
public IReadOnlyList<ControlViewModel> Items => items;
|
||||
|
||||
/// <summary>기준(Primary) 선택</summary>
|
||||
public ControlViewModel? Primary { get; private set; }
|
||||
|
||||
/// <summary>Primary 가 속한 페이지 — 컨트롤 추가 대상 결정용</summary>
|
||||
public PageViewModel? ActivePage { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
/// <summary>선택 변경 통지(경계 이동 포함) — 오버레이 구독</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>선택 '집합' 변경 통지(Set/Toggle/Clear 만) — 인스펙터 재구성 구독(드래그 프레임 제외)</summary>
|
||||
public event Action? SetChanged;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>선택 교체</summary>
|
||||
public void Set(IEnumerable<ControlViewModel> newItems, ControlViewModel? primary = null)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
item.IsSelected = false;
|
||||
}
|
||||
items.Clear();
|
||||
foreach (var item in newItems.Where(i => !i.Model.Locked))
|
||||
{
|
||||
items.Add(item);
|
||||
item.IsSelected = true;
|
||||
}
|
||||
Primary = primary is not null && items.Contains(primary) ? primary : items.FirstOrDefault();
|
||||
Changed?.Invoke();
|
||||
SetChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>단일 선택</summary>
|
||||
public void SetSingle(ControlViewModel item) => Set(new[] { item }, item);
|
||||
|
||||
/// <summary>토글(Ctrl/Shift+클릭)</summary>
|
||||
public void Toggle(ControlViewModel item)
|
||||
{
|
||||
if (items.Contains(item))
|
||||
{
|
||||
items.Remove(item);
|
||||
item.IsSelected = false;
|
||||
if (Primary == item)
|
||||
{
|
||||
Primary = items.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
else if (!item.Model.Locked)
|
||||
{
|
||||
items.Add(item);
|
||||
item.IsSelected = true;
|
||||
Primary = item;
|
||||
}
|
||||
Changed?.Invoke();
|
||||
SetChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>선택 해제</summary>
|
||||
public void Clear() => Set(Array.Empty<ControlViewModel>());
|
||||
|
||||
/// <summary>이동/리사이즈 중 오버레이 갱신 트리거</summary>
|
||||
public void NotifyBoundsChanged() => Changed?.Invoke();
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>스냅 결과 — 보정된 이동량과 표시할 가이드 좌표(월드)</summary>
|
||||
public readonly record struct SnapResult(double Dx, double Dy, double? GuideX, double? GuideY);
|
||||
|
||||
/// <summary>
|
||||
/// 그리드/정렬 스냅 순수 계산.
|
||||
/// 드래그 시작 시 후보(비선택 형제의 좌/중/우·상/중/하 + 용지 경계/중앙)를 1회 사전수집해
|
||||
/// 프레임당 재수집을 피한다.
|
||||
/// </summary>
|
||||
public sealed class SnapEngine
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly List<double> candidatesX = new();
|
||||
private readonly List<double> candidatesY = new();
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>그리드 간격(px)</summary>
|
||||
public double GridSize { get; set; } = 4;
|
||||
|
||||
/// <summary>정렬 스냅 허용 거리(px)</summary>
|
||||
public double Tolerance { get; set; } = 6;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>드래그 시작 — 스냅 후보 사전수집(월드 좌표)</summary>
|
||||
public void BeginDrag(PageViewModel page, IReadOnlySet<ControlViewModel> moving)
|
||||
{
|
||||
candidatesX.Clear();
|
||||
candidatesY.Clear();
|
||||
|
||||
// 용지 경계·중앙
|
||||
candidatesX.Add(0);
|
||||
candidatesX.Add(page.WidthDip / 2);
|
||||
candidatesX.Add(page.WidthDip);
|
||||
candidatesY.Add(page.OffsetY);
|
||||
candidatesY.Add(page.OffsetY + page.HeightDip / 2);
|
||||
candidatesY.Add(page.OffsetY + page.HeightDip);
|
||||
|
||||
foreach (var sibling in page.Controls)
|
||||
{
|
||||
if (moving.Contains(sibling) || sibling.Model.Hidden)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
candidatesX.Add(sibling.X);
|
||||
candidatesX.Add(sibling.X + sibling.Width / 2);
|
||||
candidatesX.Add(sibling.X + sibling.Width);
|
||||
|
||||
var top = page.OffsetY + sibling.Y;
|
||||
candidatesY.Add(top);
|
||||
candidatesY.Add(top + sibling.Height / 2);
|
||||
candidatesY.Add(top + sibling.Height);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이동 스냅 — bbox(월드)에 (dx,dy) 적용 시 정렬 후보와 최근접 정합.
|
||||
/// free=true(Alt)면 스냅 없이 원본 이동량 그대로.
|
||||
/// </summary>
|
||||
public SnapResult SnapMove(Rect bbox, double dx, double dy, bool free)
|
||||
{
|
||||
if (free)
|
||||
{
|
||||
return new SnapResult(dx, dy, null, null);
|
||||
}
|
||||
|
||||
var movedLeft = bbox.X + dx;
|
||||
var movedTop = bbox.Y + dy;
|
||||
|
||||
var (adjustX, guideX) = SnapAxis(new[] { movedLeft, movedLeft + bbox.Width / 2, movedLeft + bbox.Width }, candidatesX);
|
||||
var (adjustY, guideY) = SnapAxis(new[] { movedTop, movedTop + bbox.Height / 2, movedTop + bbox.Height }, candidatesY);
|
||||
|
||||
// 정합 실패 축은 그리드 스냅
|
||||
var resultDx = guideX is not null ? dx + adjustX : Math.Round(movedLeft / GridSize) * GridSize - bbox.X;
|
||||
var resultDy = guideY is not null ? dy + adjustY : Math.Round(movedTop / GridSize) * GridSize - bbox.Y;
|
||||
|
||||
return new SnapResult(resultDx, resultDy, guideX, guideY);
|
||||
}
|
||||
|
||||
/// <summary>리사이즈 스냅 — 움직이는 모서리 좌표만 정합(간이: 그리드 우선)</summary>
|
||||
public double SnapEdge(double value, bool isXAxis, bool free)
|
||||
{
|
||||
if (free)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
var candidates = isXAxis ? candidatesX : candidatesY;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (Math.Abs(candidate - value) <= Tolerance)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return Math.Round(value / GridSize) * GridSize;
|
||||
}
|
||||
|
||||
private (double Adjust, double? Guide) SnapAxis(double[] edges, List<double> candidates)
|
||||
{
|
||||
var best = double.MaxValue;
|
||||
double adjust = 0;
|
||||
double? guide = null;
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var distance = candidate - edge;
|
||||
if (Math.Abs(distance) <= Tolerance && Math.Abs(distance) < Math.Abs(best))
|
||||
{
|
||||
best = distance;
|
||||
adjust = distance;
|
||||
guide = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (adjust, guide);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 라이트/다크 테마 전환 — App.Resources 의 토큰 사전(Tokens.Dark↔Light)을 교체하면
|
||||
/// {DynamicResource B.*} 를 참조하는 모든 스타일이 라이브 리스킨된다([200]SheetMe SwapThemeTokens 이식).
|
||||
/// 선택은 %LocalAppData%\SheetMe\theme.json 에 보존.
|
||||
/// </summary>
|
||||
public static class ThemeManager
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly string SettingsPath =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SheetMe", "theme.json");
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>현재 라이트 테마 여부(기본 다크 — [200]SheetMe 기본값과 동일)</summary>
|
||||
public static bool IsLight { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>시작 시 저장된 테마 적용</summary>
|
||||
public static void LoadSaved()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(SettingsPath) &&
|
||||
JsonDocument.Parse(File.ReadAllText(SettingsPath)).RootElement.TryGetProperty("theme", out var theme) &&
|
||||
theme.GetString() == "light")
|
||||
{
|
||||
Apply(light: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 설정 손상 시 기본(다크) 유지
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>라이트↔다크 토글</summary>
|
||||
public static void Toggle() => Apply(!IsLight);
|
||||
|
||||
/// <summary>테마 적용 — App.Resources 병합 사전에서 토큰 dict 를 찾아 교체</summary>
|
||||
public static void Apply(bool light)
|
||||
{
|
||||
IsLight = light;
|
||||
var dictionaries = Application.Current.Resources.MergedDictionaries;
|
||||
var tokensUri = new Uri($"/Themes/Tokens.{(light ? "Light" : "Dark")}.xaml", UriKind.Relative);
|
||||
for (var i = 0; i < dictionaries.Count; i++)
|
||||
{
|
||||
var source = dictionaries[i].Source?.OriginalString ?? string.Empty;
|
||||
if (source.EndsWith("Tokens.Dark.xaml", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.EndsWith("Tokens.Light.xaml", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
dictionaries[i] = new ResourceDictionary { Source = tokensUri };
|
||||
Save();
|
||||
return;
|
||||
}
|
||||
}
|
||||
dictionaries.Add(new ResourceDictionary { Source = tokensUri });
|
||||
Save();
|
||||
}
|
||||
|
||||
private static void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
|
||||
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(new { theme = IsLight ? "light" : "dark" }));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 저장 실패는 무시(다음 실행 기본 테마)
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 문서 스냅샷(메멘토) Undo/Redo — 직렬화 대신 모델 딥클론(편집 전용 상태 포함) 사용.
|
||||
/// 규약: 변경 '직전' Snapshot() 1회. 드래그는 첫 실이동에서 1회 = 드래그 전체가 1스텝.
|
||||
/// 방향키 넛지는 400ms 코얼레스.
|
||||
/// </summary>
|
||||
public sealed class UndoService
|
||||
{
|
||||
#region Member Fields
|
||||
private const int Capacity = 100;
|
||||
private readonly List<FormDocument> undoStack = new();
|
||||
private readonly List<FormDocument> redoStack = new();
|
||||
private readonly Func<FormDocument> getDocument;
|
||||
private readonly Action<FormDocument> restoreDocument;
|
||||
private DateTime lastNudgeAt = DateTime.MinValue;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>Undo 가능 여부</summary>
|
||||
public bool CanUndo => undoStack.Count > 0;
|
||||
|
||||
/// <summary>Redo 가능 여부</summary>
|
||||
public bool CanRedo => redoStack.Count > 0;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public UndoService(Func<FormDocument> getDocument, Action<FormDocument> restoreDocument)
|
||||
{
|
||||
this.getDocument = getDocument;
|
||||
this.restoreDocument = restoreDocument;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
/// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary>
|
||||
public event Action? Restored;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>변경 직전 스냅샷 적재 — redo 클리어</summary>
|
||||
public void Snapshot()
|
||||
{
|
||||
undoStack.Add(getDocument().Clone());
|
||||
if (undoStack.Count > Capacity)
|
||||
{
|
||||
undoStack.RemoveRange(0, undoStack.Count - Capacity);
|
||||
}
|
||||
redoStack.Clear();
|
||||
lastNudgeAt = DateTime.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary>
|
||||
public void SnapshotForNudge()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if ((now - lastNudgeAt).TotalMilliseconds > 400)
|
||||
{
|
||||
Snapshot();
|
||||
}
|
||||
lastNudgeAt = now;
|
||||
}
|
||||
|
||||
/// <summary>실행 취소</summary>
|
||||
public void Undo()
|
||||
{
|
||||
if (!CanUndo)
|
||||
{
|
||||
return;
|
||||
}
|
||||
redoStack.Add(getDocument().Clone());
|
||||
var snapshot = undoStack[^1];
|
||||
undoStack.RemoveAt(undoStack.Count - 1);
|
||||
restoreDocument(snapshot);
|
||||
Restored?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>다시 실행</summary>
|
||||
public void Redo()
|
||||
{
|
||||
if (!CanRedo)
|
||||
{
|
||||
return;
|
||||
}
|
||||
undoStack.Add(getDocument().Clone());
|
||||
var snapshot = redoStack[^1];
|
||||
redoStack.RemoveAt(redoStack.Count - 1);
|
||||
restoreDocument(snapshot);
|
||||
Restored?.Invoke();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWPF>true</UseWPF>
|
||||
<RootNamespace>SheetMe.Designer</RootNamespace>
|
||||
<AssemblyName>SheetMe.Designer</AssemblyName>
|
||||
<ApplicationIcon>Assets\sheetme.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="Assets\sheetme.ico" />
|
||||
<Resource Include="Assets\sheetme-logo.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SheetMe.Core\SheetMe.Core.csproj" />
|
||||
<ProjectReference Include="..\SheetMe.Data\SheetMe.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="M.Framework.WPF" Version="6.5.3" />
|
||||
<PackageReference Include="M.Framework.LogManager" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 실접속 정보는 Debug 빌드에서만 산출물에 복사한다 — Release 퍼블리시에 비밀값이 실리지 않게. -->
|
||||
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<None Update="appsettings.Development.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,806 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework">
|
||||
|
||||
<!-- ============================================================
|
||||
디자이너 공유 테마 (암시 스타일 + 컨트롤 템플릿)
|
||||
· 색상은 {DynamicResource B.*} 로 참조 → 토큰 dict(Tokens.Dark/Light.xaml) 교체로 라이트/다크 전환
|
||||
· MainWindow.Resources 에 [이 파일 + 토큰 dict] 머지(토큰 dict 만 스왑)
|
||||
· 스타일 키(Subtle/CaptionBtn/B.ComboItem/ScrollThumb…)는 StaticResource 유지
|
||||
============================================================ -->
|
||||
|
||||
<!-- ===== 창 공통(다이얼로그 배경/폰트 — 로컬 지정이 있으면 로컬 우선) ===== -->
|
||||
<Style TargetType="Window">
|
||||
<Setter Property="Background" Value="{DynamicResource B.Panel}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
</Style>
|
||||
|
||||
<!-- ===== 타이포 ===== -->
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
|
||||
</Style>
|
||||
|
||||
<!-- ===== 버튼 (기본: 은은한 다크 채움) ===== -->
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Input}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
|
||||
</Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.BtnHover}" />
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.BtnPressed}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="bd" Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 강조 버튼 -->
|
||||
<Style x:Key="Primary" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Background" Value="{DynamicResource B.Accent}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.OnAccent}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Accent}" />
|
||||
</Style>
|
||||
|
||||
<!-- 텍스트형(테두리 없는) 버튼 — 툴바 아이콘/네비 -->
|
||||
<Style x:Key="Subtle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
</Style>
|
||||
|
||||
<!-- ===== 텍스트박스 ===== -->
|
||||
<!-- Framer 식 입력칩: 채움만(평소 테두리 없음)·라운드8·높이30·세로 가운데 -->
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.InputBorder}" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Input}" />
|
||||
<Setter Property="Padding" Value="4,0" />
|
||||
<Setter Property="MinHeight" Value="30" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CaretBrush" Value="{DynamicResource B.Accent}" />
|
||||
<Setter Property="SelectionBrush" Value="{DynamicResource B.Accent}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="8" SnapsToDevicePixels="True">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
|
||||
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
|
||||
</Trigger>
|
||||
<Trigger Property="AcceptsReturn" Value="True">
|
||||
<Setter TargetName="PART_ContentHost" Property="VerticalContentAlignment" Value="Stretch" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Framer 식 슬라이더(불투명도 등): 얇은 트랙 + 흰 원형 썸 -->
|
||||
<Style TargetType="Slider">
|
||||
<Setter Property="MinHeight" Value="20" />
|
||||
<Setter Property="IsMoveToPointEnabled" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Slider">
|
||||
<Grid VerticalAlignment="Center">
|
||||
<Border Height="4" CornerRadius="2" Background="{DynamicResource B.Chip}" VerticalAlignment="Center" />
|
||||
<Track x:Name="PART_Track">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Command="Slider.DecreaseLarge" Focusable="False">
|
||||
<RepeatButton.Template>
|
||||
<ControlTemplate TargetType="RepeatButton">
|
||||
<Border Height="4" CornerRadius="2" Background="{DynamicResource B.Accent}" VerticalAlignment="Center" />
|
||||
</ControlTemplate>
|
||||
</RepeatButton.Template>
|
||||
</RepeatButton>
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Command="Slider.IncreaseLarge" Focusable="False">
|
||||
<RepeatButton.Template>
|
||||
<ControlTemplate TargetType="RepeatButton"><Border Background="Transparent" /></ControlTemplate>
|
||||
</RepeatButton.Template>
|
||||
</RepeatButton>
|
||||
</Track.IncreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb>
|
||||
<Thumb.Template>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Ellipse Width="13" Height="13" Fill="White" Stroke="{DynamicResource B.SliderRing}" StrokeThickness="1" />
|
||||
</ControlTemplate>
|
||||
</Thumb.Template>
|
||||
</Thumb>
|
||||
</Track.Thumb>
|
||||
</Track>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 리스트 ===== -->
|
||||
<Style TargetType="ListBox">
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
</Style>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="Padding" Value="6,5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="5" Padding="{TemplateBinding Padding}" Margin="2,1">
|
||||
<!-- string Content 가 앱 라이트 TextBlock 스타일(어두운색)로 떨어지지 않게 밝은색 지역 지정(ComboBoxItem 과 동일 패턴) -->
|
||||
<ContentPresenter TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock"><Setter Property="Foreground" Value="{DynamicResource B.Ink}" /></Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Sel}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 콤보박스 ===== -->
|
||||
<Style x:Key="B.ComboItem" TargetType="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Padding" Value="8,5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="4" Padding="{TemplateBinding Padding}" Margin="2,1">
|
||||
<!-- 생성되는 글자 TextBlock 이 앱 라이트 TextBlock 스타일(어두운색)로 떨어지지 않게, 여기서 밝은색 스타일을 지역 지정 -->
|
||||
<ContentPresenter>
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock"><Setter Property="Foreground" Value="{DynamicResource B.Ink}" /></Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Sel}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<!-- 전역 암시 콤보 항목(드롭다운 밖 용도 대비) — 키 스타일 상속 -->
|
||||
<Style TargetType="ComboBoxItem" BasedOn="{StaticResource B.ComboItem}" />
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Input}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Height" Value="30" />
|
||||
<!-- 드롭다운 항목 스타일을 컨테이너에 직접 지정(생성기가 적용 → 앱 라이트 ComboBoxItem 누수보다 우선) -->
|
||||
<Setter Property="ItemContainerStyle" Value="{StaticResource B.ComboItem}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton Focusable="False" ClickMode="Press"
|
||||
IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="bd" Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}"
|
||||
BorderThickness="1" CornerRadius="8" SnapsToDevicePixels="True">
|
||||
<Path x:Name="arr" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,9,0"
|
||||
Data="M0,0 L4,4 L8,0" Stroke="{DynamicResource B.Muted}" StrokeThickness="1.4"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
<ContentPresenter x:Name="cp" Margin="9,0,26,0" VerticalAlignment="Center" HorizontalAlignment="Left"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
TextElement.Foreground="{DynamicResource B.Ink}"
|
||||
IsHitTestVisible="False">
|
||||
<!-- 닫힌 표시 텍스트(SelectionBoxItem)가 앱 라이트 TextBlock 스타일(어두운색)로 떨어지지 않게 스코프 지정 -->
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock"><Setter Property="Foreground" Value="{DynamicResource B.Ink}" /></Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
<!-- 편집 가능 콤보(글꼴·형식 등)의 입력/표시 텍스트 -->
|
||||
<TextBox x:Name="PART_EditableTextBox" Margin="8,0,26,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource B.Ink}" CaretBrush="{DynamicResource B.Accent}"
|
||||
Background="Transparent" Visibility="Collapsed" IsReadOnly="{TemplateBinding IsReadOnly}">
|
||||
<TextBox.Template>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Focusable="False"
|
||||
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
|
||||
</ControlTemplate>
|
||||
</TextBox.Template>
|
||||
</TextBox>
|
||||
<!-- HorizontalOffset(-10)+좌우 Margin(10): 그림자 여백 확보하면서 드롭다운을 콤보 좌측에 정렬 -->
|
||||
<Popup IsOpen="{TemplateBinding IsDropDownOpen}" Placement="Bottom" AllowsTransparency="True"
|
||||
Focusable="False" PopupAnimation="Fade" HorizontalOffset="-10">
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
CornerRadius="5" Margin="10,3,10,12" Padding="2"
|
||||
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="14" ShadowDepth="2" Direction="270" Opacity="0.22" Color="#000000" RenderingBias="Quality"/>
|
||||
</Border.Effect>
|
||||
<!-- 드롭다운(별도 팝업)이 앱 라이트 ComboBoxItem 으로 새지 않게, 팝업 안에 다크 항목 스타일을 지역 지정 -->
|
||||
<Border.Resources>
|
||||
<Style TargetType="ComboBoxItem" BasedOn="{StaticResource B.ComboItem}" />
|
||||
</Border.Resources>
|
||||
<ScrollViewer MaxHeight="260"><ItemsPresenter /></ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsEditable" Value="True">
|
||||
<Setter TargetName="cp" Property="Visibility" Value="Collapsed" />
|
||||
<Setter TargetName="PART_EditableTextBox" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 탭 (좌측 패널) ===== -->
|
||||
<Style TargetType="TabControl">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabControl">
|
||||
<DockPanel>
|
||||
<!-- 세그먼트(알약) 탭 바 — FlutterFlow 식: 둥근 컨테이너 안에 알약 탭 -->
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource B.Panel}" BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8">
|
||||
<Border Background="{DynamicResource B.Chip}" CornerRadius="9" Padding="3">
|
||||
<UniformGrid Rows="1" IsItemsHost="True" />
|
||||
</Border>
|
||||
</Border>
|
||||
<ContentPresenter ContentSource="SelectedContent" />
|
||||
</DockPanel>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style TargetType="TabItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="6" Margin="2,0" Padding="6,6">
|
||||
<ContentPresenter ContentSource="Header" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.TabHover}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.TabSel}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 문서 탭 (중앙 상단 — 크롬식: 라운드 상단, 활성=Surface+테두리) ===== -->
|
||||
<Style x:Key="DocTabs" TargetType="TabControl">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabControl">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource B.Titlebar}"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8,5,8,0">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Bottom">
|
||||
<StackPanel Orientation="Horizontal" IsItemsHost="True" />
|
||||
<!-- 새 서식 탭(+) — 탭 흐름 바로 옆([200] 문서탭 관행) -->
|
||||
<Button Content="+" Command="{Binding NewFileCommand}" Style="{StaticResource Subtle}"
|
||||
Width="26" Height="24" Padding="0" Margin="2,0,0,3" FontSize="13"
|
||||
VerticalAlignment="Center" ToolTip="새 서식 탭"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<ContentPresenter ContentSource="SelectedContent" />
|
||||
</DockPanel>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="DocTabItem" TargetType="TabItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border x:Name="bd" CornerRadius="6,6,0,0" Padding="11,5,6,6" Margin="0,0,3,0"
|
||||
Background="Transparent" BorderBrush="Transparent" BorderThickness="1,1,1,0"
|
||||
SnapsToDevicePixels="True">
|
||||
<ContentPresenter ContentSource="Header" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.TabHover}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Surface}" />
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 좌측 탭 헤더 텍스트 — 미선택=Muted, 선택=Ink+Bold(알약 위 밝은 글씨). 템플릿 색 상속 불안정 우회 -->
|
||||
<Style x:Key="TabHeaderText" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=TabItem}}" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 토글 버튼 ===== -->
|
||||
<Style TargetType="ToggleButton">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
|
||||
</Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Sel}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="bd" Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 체크박스 ===== -->
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<StackPanel Orientation="Horizontal" Background="Transparent">
|
||||
<Border x:Name="box" Width="17" Height="17" CornerRadius="4" BorderThickness="1.3"
|
||||
BorderBrush="{DynamicResource B.Line2}" Background="{DynamicResource B.Input}" VerticalAlignment="Center" SnapsToDevicePixels="True">
|
||||
<Path x:Name="chk" Data="M 3.5 8 L 7 11 L 13 4.5" Stroke="{DynamicResource B.OnAccent}" StrokeThickness="2"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round" Visibility="Collapsed" />
|
||||
</Border>
|
||||
<ContentPresenter Margin="7,0,0,0" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="box" Property="Background" Value="{DynamicResource B.Accent}" />
|
||||
<Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource B.Accent}" />
|
||||
<Setter TargetName="chk" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource B.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 익스팬더 (인스펙터 섹션) ===== -->
|
||||
<Style TargetType="Expander">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Expander">
|
||||
<DockPanel Background="Transparent">
|
||||
<ToggleButton DockPanel.Dock="Top" Focusable="False" Cursor="Hand"
|
||||
Content="{TemplateBinding Header}" ContentTemplate="{TemplateBinding HeaderTemplate}"
|
||||
IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="hb" Background="Transparent" CornerRadius="5" Padding="2,6">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path x:Name="arrow" Data="M 0 0 L 4 4 L 8 0" Stroke="{DynamicResource B.Muted}" StrokeThickness="1.6"
|
||||
Margin="2,1,8,0" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
|
||||
<Path.RenderTransform><RotateTransform Angle="-90" /></Path.RenderTransform>
|
||||
</Path>
|
||||
<ContentPresenter VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="arrow" Property="RenderTransform">
|
||||
<Setter.Value><RotateTransform Angle="0" /></Setter.Value>
|
||||
</Setter>
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="hb" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
<ContentPresenter x:Name="content" DockPanel.Dock="Bottom" Visibility="Collapsed" Margin="8,2,2,10" />
|
||||
</DockPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsExpanded" Value="True">
|
||||
<Setter TargetName="content" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 카드 / 패널헤더 / 칩 ===== -->
|
||||
<Style x:Key="Card" TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="12" />
|
||||
</Style>
|
||||
<Style x:Key="PanelHeader" TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
</Style>
|
||||
<Style x:Key="Chip" TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource B.Chip}" />
|
||||
<Setter Property="CornerRadius" Value="20" />
|
||||
<Setter Property="Padding" Value="9,2" />
|
||||
</Style>
|
||||
|
||||
<!-- ===== 메뉴 (타이틀바) ===== -->
|
||||
<Style TargetType="Menu">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
</Style>
|
||||
<Style TargetType="Separator">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Separator">
|
||||
<Border Height="1" Background="{DynamicResource B.Line}" Margin="8,4" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style TargetType="MenuItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Padding" Value="10,6" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="MenuItem">
|
||||
<Grid TextElement.Foreground="{DynamicResource B.Ink}">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="4" Padding="{TemplateBinding Padding}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ContentPresenter Grid.Column="0" x:Name="icon" ContentSource="Icon" Margin="0,0,8,0" VerticalAlignment="Center" />
|
||||
<ContentPresenter Grid.Column="1" ContentSource="Header" VerticalAlignment="Center" RecognizesAccessKey="True" />
|
||||
<TextBlock Grid.Column="2" x:Name="gesture" Text="{TemplateBinding InputGestureText}" Foreground="{DynamicResource B.Muted}"
|
||||
Margin="20,0,0,0" VerticalAlignment="Center" />
|
||||
<Path Grid.Column="2" x:Name="sub" Visibility="Collapsed" Data="M0,0 L4,4 L0,8"
|
||||
Stroke="{DynamicResource B.Muted}" StrokeThickness="1.4" Margin="14,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Popup x:Name="pop" Placement="Bottom" IsOpen="{TemplateBinding IsSubmenuOpen}" AllowsTransparency="True"
|
||||
Focusable="False" PopupAnimation="Fade">
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="4" Margin="0,3,0,0">
|
||||
<StackPanel IsItemsHost="True" />
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="Role" Value="SubmenuHeader">
|
||||
<Setter TargetName="pop" Property="Placement" Value="Right" />
|
||||
<Setter TargetName="sub" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<!-- 최상위 메뉴(파일/편집…)는 아이콘·단축키 칸을 접어 폭을 좁힘 -->
|
||||
<Trigger Property="Role" Value="TopLevelHeader">
|
||||
<Setter TargetName="icon" Property="Visibility" Value="Collapsed" />
|
||||
<Setter TargetName="gesture" Property="Visibility" Value="Collapsed" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.4" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 우클릭 컨텍스트 메뉴 (다크 컨테이너) — 캔버스 우클릭 메뉴가 기본 라이트 팝업으로 안 깨지게 ===== -->
|
||||
<Style TargetType="ContextMenu">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ContextMenu">
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="4" SnapsToDevicePixels="True">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 창 캡션 버튼 (최소화/최대화/닫기) ===== -->
|
||||
<Style x:Key="CaptionBtn" TargetType="Button">
|
||||
<Setter Property="Width" Value="46" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.CaptionFg}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="Cursor" Value="Arrow" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextElement.FontFamily="{Binding FontFamily, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
TextElement.FontSize="{Binding FontSize, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource B.CaptionHover}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.CaptionFgHover}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
<Style x:Key="CloseBtn" TargetType="Button" BasedOn="{StaticResource CaptionBtn}">
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource B.CloseHover}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 컬럼 스플리터 (사이드바 너비 드래그 조절) ===== -->
|
||||
<Style x:Key="ColSplitter" TargetType="GridSplitter">
|
||||
<Setter Property="Width" Value="5" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="ResizeBehavior" Value="PreviousAndNext" />
|
||||
<Setter Property="ResizeDirection" Value="Columns" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="Cursor" Value="SizeWE" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="GridSplitter">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" SnapsToDevicePixels="True" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 행 스플리터 (팔레트 ↔ 레이어 높이 조절) ===== -->
|
||||
<Style x:Key="RowSplitter" TargetType="GridSplitter">
|
||||
<Setter Property="Height" Value="6" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="ResizeBehavior" Value="PreviousAndNext" />
|
||||
<Setter Property="ResizeDirection" Value="Rows" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="Cursor" Value="SizeNS" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="GridSplitter">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" SnapsToDevicePixels="True" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 스크롤바 (슬림 다크) — 모든 ScrollViewer/ListBox/캔버스에 일괄 적용 ===== -->
|
||||
<Style x:Key="ScrollThumb" TargetType="Thumb">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<!-- 막대 최소 크기 — 아이템이 많아도 실처럼 가늘어지지 않게(트랙보다 길면 WPF가 트랙 길이로 클램프) -->
|
||||
<Setter Property="MinHeight" Value="48" />
|
||||
<Setter Property="MinWidth" Value="48" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border x:Name="t" CornerRadius="4" Background="{DynamicResource B.ScrollThumb}" Margin="2" SnapsToDevicePixels="True" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="t" Property="Background" Value="{DynamicResource B.ScrollThumbHover}" /></Trigger>
|
||||
<Trigger Property="IsDragging" Value="True"><Setter TargetName="t" Property="Background" Value="{DynamicResource B.ScrollThumbDrag}" /></Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="ScrollPageBtn" TargetType="RepeatButton">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RepeatButton"><Border Background="Transparent" /></ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<!-- 슬림 스크롤바 — 전 ScrollViewer/ListBox 공통(암시 적용) -->
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Width" Value="11" />
|
||||
<Setter Property="MinWidth" Value="11" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Grid Background="{TemplateBinding Background}" SnapsToDevicePixels="True">
|
||||
<Track x:Name="PART_Track" Orientation="{TemplateBinding Orientation}" IsDirectionReversed="True">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Style="{StaticResource ScrollPageBtn}" Command="ScrollBar.PageUpCommand" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{StaticResource ScrollThumb}" />
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Style="{StaticResource ScrollPageBtn}" Command="ScrollBar.PageDownCommand" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter Property="Width" Value="Auto" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="Height" Value="11" />
|
||||
<Setter Property="MinHeight" Value="11" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,46 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ============================================================
|
||||
디자이너 다크 토큰 (Framer 다크 팔레트, framer.com 에디터 기준)
|
||||
· DesignerTheme.xaml 의 스타일이 {DynamicResource B.*} 로 참조
|
||||
· 라이트 대응본 = Tokens.Light.xaml (동일 키)
|
||||
============================================================ -->
|
||||
|
||||
<!-- 색상 토큰 -->
|
||||
<SolidColorBrush x:Key="B.Ink" Color="#EDEDED" />
|
||||
<SolidColorBrush x:Key="B.Muted" Color="#969696" />
|
||||
<SolidColorBrush x:Key="B.Line" Color="#272727" />
|
||||
<SolidColorBrush x:Key="B.Line2" Color="#383838" />
|
||||
<SolidColorBrush x:Key="B.Accent" Color="#0099FF" />
|
||||
<SolidColorBrush x:Key="B.Accent2" Color="#2A2A2A" />
|
||||
<SolidColorBrush x:Key="B.Sel" Color="#3C3C3C" /><!-- 선택/체크 강조 채움(입력칩 위에서 또렷) -->
|
||||
<SolidColorBrush x:Key="B.Hover" Color="#242424" />
|
||||
<SolidColorBrush x:Key="B.AppBg" Color="#0E0E0E" />
|
||||
<SolidColorBrush x:Key="B.Panel" Color="#151515" />
|
||||
<SolidColorBrush x:Key="B.PanelHeader" Color="#1C1C1C" />
|
||||
<SolidColorBrush x:Key="B.Surface" Color="#1C1C1C" />
|
||||
<SolidColorBrush x:Key="B.Titlebar" Color="#0E0E0E" />
|
||||
<SolidColorBrush x:Key="B.Input" Color="#262626" />
|
||||
<SolidColorBrush x:Key="B.InputBorder" Color="Transparent" /><!-- 다크는 입력 테두리 없음(Framer 식 채움만) -->
|
||||
<SolidColorBrush x:Key="B.CanvasBg" Color="#2A2A2A" />
|
||||
<SolidColorBrush x:Key="B.Chip" Color="#262626" />
|
||||
<SolidColorBrush x:Key="B.Success" Color="#4CAF7D" />
|
||||
<SolidColorBrush x:Key="B.Dark" Color="#0A0A0A" />
|
||||
|
||||
<!-- 트리거/템플릿에서 추출한 토큰(라이트 전환 위해) -->
|
||||
<SolidColorBrush x:Key="B.BtnHover" Color="#34363C" />
|
||||
<SolidColorBrush x:Key="B.BtnPressed" Color="#3C3F46" />
|
||||
<SolidColorBrush x:Key="B.TabHover" Color="#2C2C2C" />
|
||||
<SolidColorBrush x:Key="B.TabSel" Color="#353535" />
|
||||
<SolidColorBrush x:Key="B.OnAccent" Color="#FFFFFF" /><!-- 강조 채움 위 글자/체크마크(양 테마 흰색) -->
|
||||
<SolidColorBrush x:Key="B.ScrollThumb" Color="#454B54" />
|
||||
<SolidColorBrush x:Key="B.ScrollThumbHover" Color="#646C79" />
|
||||
<SolidColorBrush x:Key="B.ScrollThumbDrag" Color="#7E8693" />
|
||||
<SolidColorBrush x:Key="B.CaptionFg" Color="#C7CDD4" />
|
||||
<SolidColorBrush x:Key="B.CaptionFgHover" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="B.CaptionHover" Color="#2E3138" />
|
||||
<SolidColorBrush x:Key="B.CloseHover" Color="#E04A2B" />
|
||||
<SolidColorBrush x:Key="B.SliderRing" Color="Transparent" /><!-- 다크는 테두리 없음(흰 썸 그대로) -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,46 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ============================================================
|
||||
디자이너 라이트 토큰 — Tokens.Dark.xaml 과 동일 키, 라이트값
|
||||
· Framer 라이트 에디터 기준: 중립 회색(쿨/블루톤 제거) + 강조는 Framer 블루 #0099FF
|
||||
· 회색은 무채색(R=G=B)로 통일, 블루 틴트는 선택/활성(Accent2/Sel)에만
|
||||
============================================================ -->
|
||||
|
||||
<!-- 색상 토큰 -->
|
||||
<SolidColorBrush x:Key="B.Ink" Color="#222222" />
|
||||
<SolidColorBrush x:Key="B.Muted" Color="#969696" />
|
||||
<SolidColorBrush x:Key="B.Line" Color="#EAEAEA" />
|
||||
<SolidColorBrush x:Key="B.Line2" Color="#D8D8D8" />
|
||||
<SolidColorBrush x:Key="B.Accent" Color="#0099FF" />
|
||||
<SolidColorBrush x:Key="B.Accent2" Color="#E4F2FF" /><!-- 은은한 활성 틴트(연블루) -->
|
||||
<SolidColorBrush x:Key="B.Sel" Color="#DBEEFF" /><!-- 선택 채움(입력칩 위 또렷한 블루틴트) -->
|
||||
<SolidColorBrush x:Key="B.Hover" Color="#F3F3F3" />
|
||||
<SolidColorBrush x:Key="B.AppBg" Color="#F5F5F5" />
|
||||
<SolidColorBrush x:Key="B.Panel" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="B.PanelHeader" Color="#FAFAFA" />
|
||||
<SolidColorBrush x:Key="B.Surface" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="B.Titlebar" Color="#F7F7F7" /><!-- 순백 아님(패널과 미묘한 분리) -->
|
||||
<SolidColorBrush x:Key="B.Input" Color="#FFFFFF" /><!-- 입력칩=흰색(라이트). 옅은 테두리 B.InputBorder 동반 -->
|
||||
<SolidColorBrush x:Key="B.InputBorder" Color="#E3E3E3" /><!-- 흰 입력칩 가장자리 -->
|
||||
<SolidColorBrush x:Key="B.CanvasBg" Color="#ECECEC" />
|
||||
<SolidColorBrush x:Key="B.Chip" Color="#EFEFEF" />
|
||||
<SolidColorBrush x:Key="B.Success" Color="#2E9E6B" />
|
||||
<SolidColorBrush x:Key="B.Dark" Color="#1A1A1A" />
|
||||
|
||||
<!-- 트리거/템플릿 추출 토큰 -->
|
||||
<SolidColorBrush x:Key="B.BtnHover" Color="#F3F3F3" />
|
||||
<SolidColorBrush x:Key="B.BtnPressed" Color="#EAEAEA" />
|
||||
<SolidColorBrush x:Key="B.TabHover" Color="#F0F0F0" />
|
||||
<SolidColorBrush x:Key="B.TabSel" Color="#E8E8E8" />
|
||||
<SolidColorBrush x:Key="B.OnAccent" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="B.ScrollThumb" Color="#CDCDCD" />
|
||||
<SolidColorBrush x:Key="B.ScrollThumbHover" Color="#B6B6B6" />
|
||||
<SolidColorBrush x:Key="B.ScrollThumbDrag" Color="#A2A2A2" />
|
||||
<SolidColorBrush x:Key="B.CaptionFg" Color="#444444" />
|
||||
<SolidColorBrush x:Key="B.CaptionFgHover" Color="#1A1A1A" />
|
||||
<SolidColorBrush x:Key="B.CaptionHover" Color="#E8E8E8" />
|
||||
<SolidColorBrush x:Key="B.CloseHover" Color="#E04A2B" />
|
||||
<SolidColorBrush x:Key="B.SliderRing" Color="#CFCFCF" /><!-- 라이트는 흰 썸 가장자리 링 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// 캔버스 컨트롤 ViewModel 공통 — 모델(ControlElement)의 관찰 가능한 투영.
|
||||
/// 모델이 진실이며 VM 은 버려도 되는 투영(Undo 복원 시 재생성)이다.
|
||||
/// 좌표계: WinForms px = WPF DIP 1:1.
|
||||
/// </summary>
|
||||
public abstract class ControlViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private bool isSelected;
|
||||
private bool isHovered;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>원본 모델(저장 원본 — 참조 보관)</summary>
|
||||
public ControlElement Model { get; }
|
||||
|
||||
/// <summary>부모 컨테이너 VM — 최상위(페이지 직속)면 null. X/Y 는 부모 기준 상대좌표</summary>
|
||||
public ControlViewModel? Parent { get; set; }
|
||||
|
||||
/// <summary>컨트롤 이름(Id)</summary>
|
||||
public string Id => Model.Id;
|
||||
|
||||
/// <summary>중립 타입명</summary>
|
||||
public string Type => Model.Type;
|
||||
|
||||
/// <summary>부모 기준 X(px)</summary>
|
||||
public double X
|
||||
{
|
||||
get => Model.Bounds.X;
|
||||
set { Model.Bounds.X = value; OnPropertyChanged(nameof(X)); }
|
||||
}
|
||||
|
||||
/// <summary>부모 기준 Y(px)</summary>
|
||||
public double Y
|
||||
{
|
||||
get => Model.Bounds.Y;
|
||||
set { Model.Bounds.Y = value; OnPropertyChanged(nameof(Y)); }
|
||||
}
|
||||
|
||||
/// <summary>너비(px)</summary>
|
||||
public double Width
|
||||
{
|
||||
get => Model.Bounds.W;
|
||||
set { Model.Bounds.W = value; OnPropertyChanged(nameof(Width)); }
|
||||
}
|
||||
|
||||
/// <summary>높이(px)</summary>
|
||||
public double Height
|
||||
{
|
||||
get => Model.Bounds.H;
|
||||
set { Model.Bounds.H = value; OnPropertyChanged(nameof(Height)); }
|
||||
}
|
||||
|
||||
/// <summary>선택 상태(뷰 전용 — 저장 안 함)</summary>
|
||||
public bool IsSelected
|
||||
{
|
||||
get => isSelected;
|
||||
set => SetProperty(ref isSelected, value);
|
||||
}
|
||||
|
||||
/// <summary>호버 상태(뷰 전용)</summary>
|
||||
public bool IsHovered
|
||||
{
|
||||
get => isHovered;
|
||||
set => SetProperty(ref isHovered, value);
|
||||
}
|
||||
|
||||
/// <summary>정적 텍스트(Text Property)</summary>
|
||||
public string Text => Model.Props.GetText("Text") ?? string.Empty;
|
||||
|
||||
/// <summary>유효 폰트 — 부모 체인 상속 반영(WinForms Font 상속 규약)</summary>
|
||||
public LegacyFont EffectiveFont { get; private set; } = new();
|
||||
|
||||
/// <summary>WPF FontFamily</summary>
|
||||
public FontFamily FontFamily => new(EffectiveFont.Family);
|
||||
|
||||
/// <summary>WPF FontSize(DIP) — pt × 96/72</summary>
|
||||
public double FontSize => Math.Max(1, EffectiveFont.SizePt * 96.0 / 72.0);
|
||||
|
||||
/// <summary>굵게</summary>
|
||||
public FontWeight FontWeight => EffectiveFont.Bold ? FontWeights.Bold : FontWeights.Normal;
|
||||
|
||||
/// <summary>기울임</summary>
|
||||
public FontStyle FontStyle => EffectiveFont.Italic ? FontStyles.Italic : FontStyles.Normal;
|
||||
|
||||
/// <summary>글자색 — ForeColor 상속 반영</summary>
|
||||
public Brush Foreground { get; private set; } = Brushes.Black;
|
||||
|
||||
/// <summary>배경색 — BackColor(명시 시)</summary>
|
||||
public Brush Background { get; private set; } = Brushes.Transparent;
|
||||
|
||||
/// <summary>수평 텍스트 정렬 — TextAlign 계열 Property 해석</summary>
|
||||
public HorizontalAlignment TextAlignment { get; private set; } = HorizontalAlignment.Left;
|
||||
|
||||
/// <summary>상속 기점 부모 폰트(재해석용 보관)</summary>
|
||||
public LegacyFont ParentFont { get; private set; } = new();
|
||||
|
||||
/// <summary>상속 기점 부모 글자색(재해석용 보관)</summary>
|
||||
public Brush ParentForeground { get; private set; } = Brushes.Black;
|
||||
|
||||
/// <summary>잠금 플래그(레이어 패널 토글)</summary>
|
||||
public bool IsLockedFlag
|
||||
{
|
||||
get => Model.Locked;
|
||||
set
|
||||
{
|
||||
Model.Locked = value;
|
||||
OnPropertyChanged(nameof(IsLockedFlag));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>숨김 플래그(레이어 패널 토글) — 캔버스 반투명 표시</summary>
|
||||
public bool IsHiddenFlag
|
||||
{
|
||||
get => Model.Hidden;
|
||||
set
|
||||
{
|
||||
Model.Hidden = value;
|
||||
OnPropertyChanged(nameof(IsHiddenFlag));
|
||||
OnPropertyChanged(nameof(DesignOpacity));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>디자인 표시 불투명도 — 숨김이면 0.25</summary>
|
||||
public double DesignOpacity => Model.Hidden ? 0.25 : 1.0;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
protected ControlViewModel(ControlElement model)
|
||||
{
|
||||
Model = model;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 상속 컨텍스트(부모 폰트/글자색)를 적용하고 표시 속성을 재해석한다 — DocumentMapper 가 매핑 시 호출.
|
||||
/// </summary>
|
||||
public virtual void ResolveVisualContext(LegacyFont parentFont, Brush parentForeground)
|
||||
{
|
||||
ParentFont = parentFont;
|
||||
ParentForeground = parentForeground;
|
||||
|
||||
var fontText = Model.Props.GetText("Font");
|
||||
EffectiveFont = fontText is not null ? LegacyFormat.ParseFont(fontText) : parentFont;
|
||||
|
||||
var foreText = Model.Props.GetText("ForeColor");
|
||||
Foreground = foreText is not null ? BrushFromLegacy(foreText) : parentForeground;
|
||||
|
||||
var backText = Model.Props.GetText("BackColor");
|
||||
if (backText is not null)
|
||||
{
|
||||
Background = BrushFromLegacy(backText);
|
||||
}
|
||||
|
||||
var align = Model.Props.GetText("TextAlign") ?? string.Empty;
|
||||
TextAlignment = align.Contains("Center") ? HorizontalAlignment.Center
|
||||
: align.Contains("Right") ? HorizontalAlignment.Right
|
||||
: HorizontalAlignment.Left;
|
||||
|
||||
OnPropertyChanged(string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>레거시 색 문자열 → WPF Brush</summary>
|
||||
protected static Brush BrushFromLegacy(string colorText)
|
||||
{
|
||||
var (a, r, g, b) = LegacyFormat.ParseColor(colorText);
|
||||
var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
|
||||
/// <summary>속성 변경 후 시각 재해석 — 보관된 부모 컨텍스트로 다시 해석</summary>
|
||||
public void RefreshVisual() => ResolveVisualContext(ParentFont, ParentForeground);
|
||||
|
||||
/// <summary>전체 속성 변경 통지(개명 등 계산 속성 갱신)</summary>
|
||||
public void NotifyAllChanged() => OnPropertyChanged(string.Empty);
|
||||
|
||||
/// <summary>텍스트 Property 조회 편의</summary>
|
||||
protected string? Prop(string name) => Model.Props.GetText(name);
|
||||
|
||||
/// <summary>bool Property 조회 편의 — "True"/"False" 문자열</summary>
|
||||
protected bool PropBool(string name, bool defaultValue = false)
|
||||
=> bool.TryParse(Prop(name), out var value) ? value : defaultValue;
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
// 타입별 컨트롤 ViewModel — DataTemplate 자동 선택(캔버스 렌더)의 키.
|
||||
// 각 클래스는 얇은 표시 투영이므로 한 파일에 모아 관리한다.
|
||||
|
||||
/// <summary>라벨 (레거시 Label/MFormatLabel/MSequence)</summary>
|
||||
public sealed class LabelViewModel : ControlViewModel
|
||||
{
|
||||
public LabelViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>텍스트박스 (레거시 TextBox)</summary>
|
||||
public sealed class TextBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>여러 줄 여부</summary>
|
||||
public bool Multiline => PropBool("Multiline");
|
||||
|
||||
/// <summary>테두리 표시 여부 — BorderStyle=None 이면 숨김</summary>
|
||||
public bool ShowBorder => Prop("BorderStyle") != "None";
|
||||
|
||||
public TextBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>마스크 입력 (레거시 MaskedTextBox)</summary>
|
||||
public sealed class MaskedTextBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>입력 마스크</summary>
|
||||
public string Mask => Prop("Mask") ?? string.Empty;
|
||||
|
||||
public MaskedTextBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>체크박스 (레거시 CheckBox/CheckLabel)</summary>
|
||||
public sealed class CheckBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>디자인 표시용 체크 상태</summary>
|
||||
public bool IsChecked => Prop("Checked") == "True";
|
||||
|
||||
public CheckBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>라디오버튼 (레거시 RadioButton)</summary>
|
||||
public sealed class RadioButtonViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>디자인 표시용 선택 상태</summary>
|
||||
public bool IsChecked => Prop("Checked") == "True";
|
||||
|
||||
public RadioButtonViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>콤보박스 (레거시 ComboBox)</summary>
|
||||
public sealed class ComboBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>선택지 목록 — Items Property(ItemsValue) 해석</summary>
|
||||
public IReadOnlyList<string> Items => ItemsOf(Model, "Items");
|
||||
|
||||
/// <summary>디자인 표시 텍스트 — Text 또는 첫 항목</summary>
|
||||
public string DisplayText => Text.Length > 0 ? Text : (Items.Count > 0 ? Items[0] : string.Empty);
|
||||
|
||||
public ComboBoxViewModel(ControlElement model) : base(model) { }
|
||||
|
||||
/// <summary>ItemsValue Property 를 문자열 목록으로</summary>
|
||||
internal static IReadOnlyList<string> ItemsOf(ControlElement model, string propName)
|
||||
{
|
||||
if (model.Props.Get(propName) is LegacyPropValue.ItemsValue items)
|
||||
{
|
||||
return items.Items
|
||||
.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty)
|
||||
.ToList();
|
||||
}
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>리스트박스 (레거시 ListBox)</summary>
|
||||
public sealed class ListBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>항목 목록</summary>
|
||||
public IReadOnlyList<string> Items => ComboBoxViewModel.ItemsOf(Model, "Items");
|
||||
|
||||
public ListBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>체크리스트 (레거시 MCheckedListBox)</summary>
|
||||
public sealed class CheckListViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>항목 목록</summary>
|
||||
public IReadOnlyList<string> Items => ComboBoxViewModel.ItemsOf(Model, "Items");
|
||||
|
||||
public CheckListViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>날짜선택 (레거시 DateTimePicker)</summary>
|
||||
public sealed class DateTimePickerViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>디자인 표시 텍스트 — 포맷 힌트</summary>
|
||||
public string DisplayText
|
||||
{
|
||||
get
|
||||
{
|
||||
var custom = Prop("CustomFormat");
|
||||
if (!string.IsNullOrEmpty(custom))
|
||||
{
|
||||
return custom;
|
||||
}
|
||||
return Prop("Format") switch
|
||||
{
|
||||
"Time" => "오후 12:00:00",
|
||||
"Short" => "2026-01-01",
|
||||
_ => "2026년 1월 1일",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimePickerViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>컨테이너 공통 — 자식 컬렉션 보유(Panel/GroupBox)</summary>
|
||||
public abstract class ContainerViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>자식 컨트롤(그리기 순서 — 마지막이 최상위)</summary>
|
||||
public ObservableCollection<ControlViewModel> Children { get; } = new();
|
||||
|
||||
protected ContainerViewModel(ControlElement model) : base(model) { }
|
||||
|
||||
/// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary>
|
||||
public override void ResolveVisualContext(SheetMe.Core.Serialization.LegacyFont parentFont, System.Windows.Media.Brush parentForeground)
|
||||
{
|
||||
base.ResolveVisualContext(parentFont, parentForeground);
|
||||
foreach (var child in Children)
|
||||
{
|
||||
child.ResolveVisualContext(EffectiveFont, Foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>패널 (레거시 Panel/Panel2/MLayerPanel/MExpandablePanel)</summary>
|
||||
public sealed class PanelViewModel : ContainerViewModel
|
||||
{
|
||||
/// <summary>테두리 표시 여부 — BorderStyle 존재 시(None 제외)</summary>
|
||||
public bool ShowBorder => Prop("BorderStyle") is not (null or "None");
|
||||
|
||||
public PanelViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>그룹박스 (레거시 GroupBox)</summary>
|
||||
public sealed class GroupBoxViewModel : ContainerViewModel
|
||||
{
|
||||
public GroupBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>선 (레거시 MLine)</summary>
|
||||
public sealed class LineViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>선 색 — LineColor Property</summary>
|
||||
public System.Windows.Media.Brush LineBrush
|
||||
=> Prop("LineColor") is { } color ? BrushFromLegacy(color) : System.Windows.Media.Brushes.Black;
|
||||
|
||||
public LineViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>이미지 (레거시 MPictureBox)</summary>
|
||||
public sealed class PictureBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트</summary>
|
||||
public string Hint => Prop("DataInterfaceTag") ?? "이미지";
|
||||
|
||||
public PictureBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>계산박스 (레거시 MCalcBox)</summary>
|
||||
public sealed class CalcBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>수식 원문 — Formula Property</summary>
|
||||
public string Formula => Prop("Formula") ?? string.Empty;
|
||||
|
||||
public CalcBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>버튼 (레거시 MButton : WinForms Button — 클릭 시 DataActionTag 검색폼 호출)</summary>
|
||||
public sealed class ButtonViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>액션 태그 표시(None 이면 빈 값)</summary>
|
||||
public string ActionHint
|
||||
{
|
||||
get
|
||||
{
|
||||
var tag = Prop("DataActionTag");
|
||||
return tag is null or "None" ? string.Empty : tag;
|
||||
}
|
||||
}
|
||||
|
||||
public ButtonViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스 (레거시 MDataTable — 34×34 DB 아이콘, 런타임 비가시 쿼리 소스).
|
||||
/// 디자인 화면에서만 배지로 보이고 인쇄/런타임에는 그려지지 않는다.
|
||||
/// </summary>
|
||||
public sealed class DataTableViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>쿼리 요약(툴팁/배지) — 첫 60자</summary>
|
||||
public string QuerySummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var query = (Prop("Query") ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
return query.Length == 0
|
||||
? "쿼리 미설정"
|
||||
: query.Length > 60 ? query[..60] + "…" : query;
|
||||
}
|
||||
}
|
||||
|
||||
public DataTableViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>Spread 격자선 1개(로컬 좌표)</summary>
|
||||
public sealed class SpreadLine
|
||||
{
|
||||
public double X1 { get; init; }
|
||||
public double Y1 { get; init; }
|
||||
public double X2 { get; init; }
|
||||
public double Y2 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Spread 셀 표시 1개(로컬 좌표)</summary>
|
||||
public sealed class SpreadCellView
|
||||
{
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
public double W { get; init; }
|
||||
public double H { get; init; }
|
||||
public string Text { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 표 (레거시 Spread — FarPoint). 디자인은 E_SpdMst 별도 저장 —
|
||||
/// DB 서식 열기 시 파싱된 격자(GridInfo)를 주입받아 렌더한다(읽기 전용, 편집은 레거시 디자이너 사용).
|
||||
/// </summary>
|
||||
public sealed class SpreadViewModel : ControlViewModel
|
||||
{
|
||||
private SheetMe.Core.Serialization.SpreadGridInfo? gridInfo;
|
||||
|
||||
/// <summary>파싱된 격자 정보 — 주입 시 지오메트리 재계산</summary>
|
||||
public SheetMe.Core.Serialization.SpreadGridInfo? GridInfo
|
||||
{
|
||||
get => gridInfo;
|
||||
set
|
||||
{
|
||||
gridInfo = value;
|
||||
RecalcGeometry();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>격자 정보 보유 여부(없으면 자리표시 렌더)</summary>
|
||||
public bool HasGrid => gridInfo is not null;
|
||||
|
||||
/// <summary>내부 격자선(로컬 좌표, 외곽 제외)</summary>
|
||||
public System.Collections.ObjectModel.ObservableCollection<SpreadLine> GridLines { get; } = new();
|
||||
|
||||
/// <summary>셀 텍스트(로컬 좌표, 스팬 반영)</summary>
|
||||
public System.Collections.ObjectModel.ObservableCollection<SpreadCellView> CellViews { get; } = new();
|
||||
|
||||
public SpreadViewModel(ControlElement model) : base(model)
|
||||
{
|
||||
PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is nameof(Width) or nameof(Height))
|
||||
{
|
||||
RecalcGeometry();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>격자/셀 지오메트리 재계산 — 컨트롤 크기 내 클리핑(500×500 전체가 아닌 보이는 만큼만)</summary>
|
||||
private void RecalcGeometry()
|
||||
{
|
||||
GridLines.Clear();
|
||||
CellViews.Clear();
|
||||
OnPropertyChanged(nameof(HasGrid));
|
||||
if (gridInfo is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var width = Math.Max(1, Width);
|
||||
var height = Math.Max(1, Height);
|
||||
|
||||
// 세로선(열 경계) — 누적 너비가 컨트롤 폭 안인 것만
|
||||
var x = 0.0;
|
||||
var colEdges = new List<double> { 0 };
|
||||
for (var c = 0; x < width && c < 512; c++)
|
||||
{
|
||||
x += gridInfo.ColWidthOf(c);
|
||||
if (x >= width)
|
||||
{
|
||||
break;
|
||||
}
|
||||
colEdges.Add(x);
|
||||
GridLines.Add(new SpreadLine { X1 = x, Y1 = 0, X2 = x, Y2 = height });
|
||||
}
|
||||
|
||||
// 가로선(행 경계)
|
||||
var y = 0.0;
|
||||
var rowEdges = new List<double> { 0 };
|
||||
for (var r = 0; y < height && r < 512; r++)
|
||||
{
|
||||
y += gridInfo.RowHeightOf(r);
|
||||
if (y >= height)
|
||||
{
|
||||
break;
|
||||
}
|
||||
rowEdges.Add(y);
|
||||
GridLines.Add(new SpreadLine { X1 = 0, Y1 = y, X2 = width, Y2 = y });
|
||||
}
|
||||
|
||||
// 셀 텍스트 — 스팬 반영, 보이는 영역만
|
||||
foreach (var cell in gridInfo.Cells)
|
||||
{
|
||||
var span = gridInfo.Spans.FirstOrDefault(s => s.Row == cell.Row && s.Col == cell.Col);
|
||||
var cellX = SumCols(0, cell.Col);
|
||||
var cellY = SumRows(0, cell.Row);
|
||||
if (cellX >= width || cellY >= height)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var cellW = SumCols(cell.Col, cell.Col + (span?.ColSpan ?? 1));
|
||||
var cellH = SumRows(cell.Row, cell.Row + (span?.RowSpan ?? 1));
|
||||
CellViews.Add(new SpreadCellView
|
||||
{
|
||||
X = cellX,
|
||||
Y = cellY,
|
||||
W = Math.Min(cellW, width - cellX),
|
||||
H = Math.Min(cellH, height - cellY),
|
||||
Text = cell.Text,
|
||||
});
|
||||
}
|
||||
|
||||
double SumCols(int from, int to)
|
||||
{
|
||||
var sum = 0.0;
|
||||
for (var c = from; c < to; c++)
|
||||
{
|
||||
sum += gridInfo!.ColWidthOf(c);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
double SumRows(int from, int to)
|
||||
{
|
||||
var sum = 0.0;
|
||||
for (var r = from; r < to; r++)
|
||||
{
|
||||
sum += gridInfo!.RowHeightOf(r);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>미지원 컨트롤 자리표시 — 원본 타입명 표시, 이동/리사이즈만 허용</summary>
|
||||
public sealed class PlaceholderViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>원본 레거시 클래스명</summary>
|
||||
public string LegacyClassName
|
||||
=> Model.LegacyAqn is { } aqn ? LegacyTypeCatalog.ShortClassName(aqn) : "Unknown";
|
||||
|
||||
public PlaceholderViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels.Inspector;
|
||||
|
||||
/// <summary>
|
||||
/// 속성 인스펙터 — ControlRegistry 스키마 기반 행 구성.
|
||||
/// 선택 '집합' 변경 시에만 재구성(드래그 프레임 제외), 다중선택은 값 동일성 병합("여러 값").
|
||||
/// 커밋 규약: 값 변경 시 Undo 스냅샷 1회 → 전체 선택 대상 적용 → 시각 재해석.
|
||||
/// </summary>
|
||||
public sealed class InspectorViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private const string StringItemAqn =
|
||||
"System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
|
||||
private readonly DesignerViewModel designer;
|
||||
private readonly List<(PropertyRowViewModel Row, RowBinding Binding)> boundsRows = new();
|
||||
private bool showAdvanced;
|
||||
|
||||
/// <summary>공통/글꼴 섹션이 이미 다루는 키 — 고급 목록에서 제외</summary>
|
||||
private static readonly HashSet<string> HandledKeys = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Location", "Size", "LocationOnBase", "Name", "Font",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>행 목록(섹션 헤더 포함)</summary>
|
||||
public ObservableCollection<PropertyRowViewModel> Rows { get; } = new();
|
||||
|
||||
/// <summary>선택 요약 텍스트</summary>
|
||||
public string Summary
|
||||
=> designer.Selection.Items.Count switch
|
||||
{
|
||||
0 => "선택 없음",
|
||||
1 => $"{designer.Selection.Primary!.Type} — {designer.Selection.Primary!.Id}",
|
||||
var n => $"{n}개 선택",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public InspectorViewModel(DesignerViewModel designer)
|
||||
{
|
||||
this.designer = designer;
|
||||
designer.Selection.SetChanged += Rebuild;
|
||||
designer.Selection.Changed += RefreshBoundsRows; // 드래그/리사이즈 중 X/Y/W/H 실시간 갱신
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>경계(X/Y/W/H) 행 값만 갱신 — 이동/리사이즈 프레임(재구성 없이 가볍게)</summary>
|
||||
private void RefreshBoundsRows()
|
||||
{
|
||||
var items = designer.Selection.Items;
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (var (row, binding) in boundsRows)
|
||||
{
|
||||
var values = items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
|
||||
row.Initialize(values.Count == 1 ? values[0] : null, isMixed: values.Count > 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>행 재구성 — 선택 집합 변경 시</summary>
|
||||
public void Rebuild()
|
||||
{
|
||||
Rows.Clear();
|
||||
boundsRows.Clear();
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
|
||||
var items = designer.Selection.Items;
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 공통: 이름/위치/크기
|
||||
Rows.Add(new SectionRowViewModel("공통"));
|
||||
if (items.Count == 1)
|
||||
{
|
||||
AddRow(new TextRowViewModel("이름"), new RowBinding
|
||||
{
|
||||
Get = vm => vm.Id,
|
||||
Set = (vm, value) => RenameControl(vm, value),
|
||||
AffectsVisual = false,
|
||||
});
|
||||
}
|
||||
AddBoundsRow("X", vm => vm.X, (vm, v) => vm.X = v);
|
||||
AddBoundsRow("Y", vm => vm.Y, (vm, v) => vm.Y = v);
|
||||
AddBoundsRow("너비", vm => vm.Width, (vm, v) => vm.Width = Math.Max(1, v));
|
||||
AddBoundsRow("높이", vm => vm.Height, (vm, v) => vm.Height = Math.Max(1, v));
|
||||
|
||||
// 폰트(공통) — Font Property(상속 시 빈 값)
|
||||
Rows.Add(new SectionRowViewModel("글꼴"));
|
||||
AddFontRow("글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v);
|
||||
AddFontRow("크기(pt)", f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture),
|
||||
(f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0 ? size : f.SizePt);
|
||||
AddFontToggleRow("굵게", f => f.Bold, (f, v) => f.Bold = v);
|
||||
AddFontToggleRow("밑줄", f => f.Underline, (f, v) => f.Underline = v);
|
||||
|
||||
// 타입 전용 — 전체 선택이 같은 타입일 때만
|
||||
var type = items[0].Type;
|
||||
var curatedKeys = new HashSet<string>(HandledKeys, StringComparer.Ordinal);
|
||||
if (items.All(i => i.Type == type) && ControlRegistry.Find(type) is { } descriptor && descriptor.Properties.Count > 0)
|
||||
{
|
||||
Rows.Add(new SectionRowViewModel(descriptor.DisplayName));
|
||||
foreach (var def in descriptor.Properties)
|
||||
{
|
||||
AddDefRow(def);
|
||||
curatedKeys.Add(def.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가)
|
||||
if (items.Count == 1)
|
||||
{
|
||||
BuildAdvancedRows(items[0], curatedKeys);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary>
|
||||
private void BuildAdvancedRows(ControlViewModel target, HashSet<string> curatedKeys)
|
||||
{
|
||||
var advancedKeys = target.Model.Props.Keys
|
||||
.Where(k => !curatedKeys.Contains(k))
|
||||
.ToList();
|
||||
|
||||
if (!showAdvanced)
|
||||
{
|
||||
Rows.Add(new ToggleAdvancedRowViewModel($"전체 속성 표시 ▾ ({advancedKeys.Count}개)", () =>
|
||||
{
|
||||
showAdvanced = true;
|
||||
Rebuild();
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
Rows.Add(new SectionRowViewModel("전체 속성 (고급 — 레거시 원문)"));
|
||||
Rows.Add(new ToggleAdvancedRowViewModel("전체 속성 숨기기 ▴", () =>
|
||||
{
|
||||
showAdvanced = false;
|
||||
Rebuild();
|
||||
}));
|
||||
|
||||
foreach (var key in advancedKeys)
|
||||
{
|
||||
var value = target.Model.Props.Get(key);
|
||||
switch (value)
|
||||
{
|
||||
case LegacyPropValue.TextValue or LegacyPropValue.NullValue or null:
|
||||
AddRow(new TextRowViewModel(key), new RowBinding
|
||||
{
|
||||
Get = vm => vm.Model.Props.GetText(key) ?? string.Empty,
|
||||
Set = (vm, newValue) => vm.Model.Props.SetText(key, newValue),
|
||||
});
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ItemsValue items:
|
||||
AddRow(new MultilineTextRowViewModel(key + " (목록)"), new RowBinding
|
||||
{
|
||||
Get = vm => vm.Model.Props.Get(key) is LegacyPropValue.ItemsValue iv
|
||||
? string.Join("\n", iv.Items.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty))
|
||||
: string.Empty,
|
||||
Set = (vm, newValue) =>
|
||||
{
|
||||
var itemsValue = new LegacyPropValue.ItemsValue();
|
||||
var aqn = items.Items.FirstOrDefault()?.Aqn ?? StringItemAqn;
|
||||
foreach (var line in newValue.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
itemsValue.Items.Add(new LegacyItem { Aqn = aqn, Value = new LegacyPropValue.TextValue(line) });
|
||||
}
|
||||
vm.Model.Props.Set(key, itemsValue);
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case LegacyPropValue.NestedValue nested:
|
||||
Rows.Add(new ReadOnlyRowViewModel(key, $"(중첩 속성 {nested.Children.Count}개 — 보존됨)"));
|
||||
break;
|
||||
|
||||
case LegacyPropValue.BinaryValue:
|
||||
Rows.Add(new ReadOnlyRowViewModel(key, "(바이너리 — 원문 보존됨)"));
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ReferenceValue reference:
|
||||
Rows.Add(new ReadOnlyRowViewModel(key, $"(참조: {reference.Name})"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 속성 추가 — 레거시 컨트롤의 임의 속성 지정(오타 주의: 로더가 모르는 키는 경고 처리)
|
||||
Rows.Add(new AddPropertyRowViewModel(key =>
|
||||
{
|
||||
if (target.Model.Props.Contains(key))
|
||||
{
|
||||
System.Windows.MessageBox.Show($"이미 존재하는 속성입니다: {key}", "속성 추가");
|
||||
return;
|
||||
}
|
||||
designer.Undo.Snapshot();
|
||||
target.Model.Props.SetText(key, string.Empty);
|
||||
Rebuild();
|
||||
}));
|
||||
}
|
||||
|
||||
private void AddDefRow(PropertyDef def)
|
||||
{
|
||||
var binding = def.Editor == PropEditorKind.StringList
|
||||
? new RowBinding
|
||||
{
|
||||
Get = vm => vm.Model.Props.Get(def.Key) is LegacyPropValue.ItemsValue items
|
||||
? string.Join("\n", items.Items.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty))
|
||||
: string.Empty,
|
||||
Set = (vm, value) =>
|
||||
{
|
||||
var itemsValue = new LegacyPropValue.ItemsValue();
|
||||
foreach (var line in value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
itemsValue.Items.Add(new LegacyItem { Aqn = StringItemAqn, Value = new LegacyPropValue.TextValue(line) });
|
||||
}
|
||||
vm.Model.Props.Set(def.Key, itemsValue);
|
||||
},
|
||||
}
|
||||
: new RowBinding
|
||||
{
|
||||
Get = vm => vm.Model.Props.GetText(def.Key),
|
||||
Set = (vm, value) =>
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
vm.Model.Props.Remove(def.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
vm.Model.Props.SetText(def.Key, value);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
PropertyRowViewModel row = def.Editor switch
|
||||
{
|
||||
PropEditorKind.MultilineText or PropEditorKind.StringList => new MultilineTextRowViewModel(def.Label),
|
||||
PropEditorKind.Number => new NumberRowViewModel(def.Label),
|
||||
PropEditorKind.Toggle => new ToggleRowViewModel(def.Label),
|
||||
PropEditorKind.Choice => new ChoiceRowViewModel(def.Label, def.Choices ?? Array.Empty<string>()),
|
||||
PropEditorKind.Color => new ColorRowViewModel(def.Label),
|
||||
PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label,
|
||||
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags),
|
||||
PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label,
|
||||
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags),
|
||||
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label),
|
||||
_ => new TextRowViewModel(def.Label),
|
||||
};
|
||||
AddRow(row, binding);
|
||||
}
|
||||
|
||||
private void AddBoundsRow(string label, Func<ControlViewModel, double> get, Action<ControlViewModel, double> set)
|
||||
{
|
||||
var row = new NumberRowViewModel(label);
|
||||
var binding = new RowBinding
|
||||
{
|
||||
Get = vm => Math.Round(get(vm)).ToString(CultureInfo.InvariantCulture),
|
||||
Set = (vm, value) =>
|
||||
{
|
||||
if (double.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var number))
|
||||
{
|
||||
set(vm, Math.Round(number));
|
||||
}
|
||||
},
|
||||
AffectsVisual = false,
|
||||
};
|
||||
AddRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged());
|
||||
boundsRows.Add((row, binding));
|
||||
}
|
||||
|
||||
private void AddFontRow(string label, Func<LegacyFont, string> get, Action<LegacyFont, string> set)
|
||||
{
|
||||
AddRow(new TextRowViewModel(label), new RowBinding
|
||||
{
|
||||
Get = vm => get(vm.EffectiveFont),
|
||||
Set = (vm, value) =>
|
||||
{
|
||||
var font = CurrentFontOf(vm);
|
||||
set(font, value);
|
||||
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private void AddFontToggleRow(string label, Func<LegacyFont, bool> get, Action<LegacyFont, bool> set)
|
||||
{
|
||||
AddRow(new ToggleRowViewModel(label), new RowBinding
|
||||
{
|
||||
Get = vm => get(vm.EffectiveFont) ? "True" : "False",
|
||||
Set = (vm, value) =>
|
||||
{
|
||||
var font = CurrentFontOf(vm);
|
||||
set(font, value == "True");
|
||||
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화</summary>
|
||||
private static LegacyFont CurrentFontOf(ControlViewModel vm)
|
||||
{
|
||||
var explicitFont = vm.Model.Props.GetText("Font");
|
||||
if (explicitFont is not null)
|
||||
{
|
||||
return LegacyFormat.ParseFont(explicitFont);
|
||||
}
|
||||
var inherited = vm.EffectiveFont;
|
||||
return new LegacyFont
|
||||
{
|
||||
Family = inherited.Family,
|
||||
SizePt = inherited.SizePt,
|
||||
Bold = inherited.Bold,
|
||||
Italic = inherited.Italic,
|
||||
Underline = inherited.Underline,
|
||||
Strikeout = inherited.Strikeout,
|
||||
};
|
||||
}
|
||||
|
||||
private void AddRow(PropertyRowViewModel row, RowBinding binding, Action? afterCommit = null)
|
||||
{
|
||||
var items = designer.Selection.Items;
|
||||
var values = items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
|
||||
row.Initialize(values.Count == 1 ? values[0] : null, isMixed: values.Count > 1);
|
||||
|
||||
row.Commit = value =>
|
||||
{
|
||||
var targets = designer.Selection.Items.ToList();
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
designer.Undo.Snapshot();
|
||||
foreach (var target in targets)
|
||||
{
|
||||
binding.Set(target, value);
|
||||
if (binding.AffectsVisual)
|
||||
{
|
||||
target.RefreshVisual();
|
||||
}
|
||||
target.NotifyAllChanged();
|
||||
}
|
||||
afterCommit?.Invoke();
|
||||
};
|
||||
Rows.Add(row);
|
||||
}
|
||||
|
||||
private void RenameControl(ControlViewModel vm, string newId)
|
||||
{
|
||||
var trimmed = newId.Trim();
|
||||
if (trimmed.Length == 0 || trimmed == vm.Id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var used = IdGenerator.CollectUsed(designer.Document);
|
||||
used.Remove(vm.Id);
|
||||
if (used.Contains(trimmed))
|
||||
{
|
||||
System.Windows.MessageBox.Show($"이미 사용 중인 이름입니다: {trimmed}", "이름 변경");
|
||||
return;
|
||||
}
|
||||
vm.Model.Id = trimmed;
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels.Inspector;
|
||||
|
||||
/// <summary>
|
||||
/// 인스펙터 행 ViewModel — 라벨 + 문자열 정규화 값. 커밋 시(값 변경 시에만) 소유자 콜백으로
|
||||
/// Undo 스냅샷 → 전체 선택 대상 적용 → 시각 재해석이 수행된다.
|
||||
/// </summary>
|
||||
public abstract class PropertyRowViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private string valueText = string.Empty;
|
||||
private bool building;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>행 라벨(한글)</summary>
|
||||
public string Label { get; }
|
||||
|
||||
/// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary>
|
||||
public bool IsMixed { get; private set; }
|
||||
|
||||
/// <summary>정규화 문자열 값 — 파생 편집기가 형 변환</summary>
|
||||
public string ValueText
|
||||
{
|
||||
get => valueText;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref valueText, value) || building)
|
||||
{
|
||||
return;
|
||||
}
|
||||
IsMixed = false;
|
||||
OnPropertyChanged(nameof(IsMixed));
|
||||
Commit?.Invoke(value);
|
||||
OnValueApplied();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>커밋 콜백 — InspectorViewModel 이 배선(스냅샷+적용)</summary>
|
||||
public Action<string>? Commit { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
protected PropertyRowViewModel(string label)
|
||||
{
|
||||
Label = label;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>초기값 세팅(커밋 미발생)</summary>
|
||||
public void Initialize(string? value, bool isMixed = false)
|
||||
{
|
||||
building = true;
|
||||
valueText = value ?? string.Empty;
|
||||
IsMixed = isMixed;
|
||||
OnPropertyChanged(nameof(ValueText));
|
||||
OnPropertyChanged(nameof(IsMixed));
|
||||
building = false;
|
||||
}
|
||||
|
||||
/// <summary>값 적용 후 파생 갱신 지점</summary>
|
||||
protected virtual void OnValueApplied() { }
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>구분 헤더 행</summary>
|
||||
public sealed class SectionRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
public SectionRowViewModel(string label) : base(label) { }
|
||||
}
|
||||
|
||||
/// <summary>한 줄 문자열 행</summary>
|
||||
public sealed class TextRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
public TextRowViewModel(string label) : base(label) { }
|
||||
}
|
||||
|
||||
/// <summary>여러 줄 문자열 행(Text/수식/항목 목록)</summary>
|
||||
public sealed class MultilineTextRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
public MultilineTextRowViewModel(string label) : base(label) { }
|
||||
}
|
||||
|
||||
/// <summary>숫자 행 — 문자열 바인딩, 커밋 시 숫자 검증은 소유자에서</summary>
|
||||
public sealed class NumberRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
public NumberRowViewModel(string label) : base(label) { }
|
||||
}
|
||||
|
||||
/// <summary>토글(참/거짓) 행</summary>
|
||||
public sealed class ToggleRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>체크 상태 — "True"/"False" 문자열과 동기</summary>
|
||||
public bool IsOn
|
||||
{
|
||||
get => ValueText == "True";
|
||||
set => ValueText = value ? "True" : "False";
|
||||
}
|
||||
|
||||
public ToggleRowViewModel(string label) : base(label) { }
|
||||
|
||||
protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn));
|
||||
}
|
||||
|
||||
/// <summary>선택지 행</summary>
|
||||
public sealed class ChoiceRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>선택지 목록</summary>
|
||||
public string[] Choices { get; }
|
||||
|
||||
public ChoiceRowViewModel(string label, string[] choices) : base(label)
|
||||
{
|
||||
Choices = choices;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 태그 피커 행 — 값 표시 + 찾아보기 버튼(검색 대화상자).
|
||||
/// 목록에 없는 사이트 커스텀 태그는 텍스트 직접 입력도 허용.
|
||||
/// </summary>
|
||||
public sealed class TagPickerRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>피커 선택지(레거시 카탈로그)</summary>
|
||||
public IReadOnlyList<string> Choices { get; }
|
||||
|
||||
/// <summary>피커 대화상자 제목</summary>
|
||||
public string PickerTitle { get; }
|
||||
|
||||
/// <summary>찾아보기 — 검색 대화상자 열기</summary>
|
||||
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
|
||||
|
||||
public TagPickerRowViewModel(string label, string pickerTitle, IReadOnlyList<string> choices) : base(label)
|
||||
{
|
||||
PickerTitle = pickerTitle;
|
||||
Choices = choices;
|
||||
BrowseCommand = new M.Framework.WPF.Command((sender, e) => OnBrowse());
|
||||
}
|
||||
|
||||
private void OnBrowse()
|
||||
{
|
||||
var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText)
|
||||
{
|
||||
Owner = System.Windows.Application.Current.MainWindow,
|
||||
};
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
ValueText = dialog.SelectedTag ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>SQL 쿼리 행 — 요약 표시 + 전용 편집기 창(치환 변수 삽입)</summary>
|
||||
public sealed class QueryRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>요약 텍스트(한 줄)</summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>전용 편집기 열기</summary>
|
||||
public M.Framework.WPF.ICustomCommand? EditCommand { get; set; }
|
||||
|
||||
public QueryRowViewModel(string label) : base(label)
|
||||
{
|
||||
EditCommand = new M.Framework.WPF.Command((sender, e) => OnEdit());
|
||||
}
|
||||
|
||||
protected override void OnValueApplied() => OnPropertyChanged(nameof(Summary));
|
||||
|
||||
private void OnEdit()
|
||||
{
|
||||
var dialog = new Views.QueryEditorWindow(Label, ValueText)
|
||||
{
|
||||
Owner = System.Windows.Application.Current.MainWindow,
|
||||
};
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
ValueText = dialog.QueryText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>색 행 — 레거시 invariant 문자열("R, G, B"/명명색) + 미리보기 스와치</summary>
|
||||
public sealed class ColorRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>미리보기 브러시</summary>
|
||||
public Brush Preview
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ValueText.Length == 0)
|
||||
{
|
||||
return Brushes.Transparent;
|
||||
}
|
||||
var (a, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(ValueText);
|
||||
var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>색상 피커 열기 — 확정 시 레거시 invariant 형식("R, G, B")으로 반영</summary>
|
||||
public M.Framework.WPF.ICustomCommand? PickCommand { get; set; }
|
||||
|
||||
public ColorRowViewModel(string label) : base(label)
|
||||
{
|
||||
PickCommand = new M.Framework.WPF.Command((sender, e) => OnPick());
|
||||
}
|
||||
|
||||
protected override void OnValueApplied() => OnPropertyChanged(nameof(Preview));
|
||||
|
||||
private void OnPick()
|
||||
{
|
||||
var initialHex = (string?)null;
|
||||
if (ValueText.Length > 0)
|
||||
{
|
||||
var (_, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(ValueText);
|
||||
initialHex = $"#{r:X2}{g:X2}{b:X2}";
|
||||
}
|
||||
var picked = Views.ColorPickerWindow.Pick(System.Windows.Application.Current.MainWindow, initialHex);
|
||||
if (picked is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var color = (Color)ColorConverter.ConvertFromString(picked);
|
||||
ValueText = Core.Serialization.LegacyFormat.FormatColor(255, color.R, color.G, color.B);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>읽기 전용 행 — 중첩/바이너리/참조 등 raw 편집 불가 값 표시</summary>
|
||||
public sealed class ReadOnlyRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
public ReadOnlyRowViewModel(string label, string display) : base(label)
|
||||
{
|
||||
Initialize(display);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>전체 속성(고급) 섹션 토글 행 — 표시/숨김 버튼</summary>
|
||||
public sealed class ToggleAdvancedRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>버튼 표시 텍스트</summary>
|
||||
public string ButtonText { get; }
|
||||
|
||||
/// <summary>토글 실행</summary>
|
||||
public M.Framework.WPF.ICustomCommand? ToggleCommand { get; set; }
|
||||
|
||||
public ToggleAdvancedRowViewModel(string buttonText, Action toggle) : base(string.Empty)
|
||||
{
|
||||
ButtonText = buttonText;
|
||||
ToggleCommand = new M.Framework.WPF.Command((sender, e) => toggle());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>속성 추가 행 — 키 입력 후 빈 속성 생성(고급)</summary>
|
||||
public sealed class AddPropertyRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
private string keyText = string.Empty;
|
||||
|
||||
/// <summary>추가할 속성 키(레거시 Property 이름)</summary>
|
||||
public string KeyText
|
||||
{
|
||||
get => keyText;
|
||||
set => SetProperty(ref keyText, value);
|
||||
}
|
||||
|
||||
/// <summary>추가 실행</summary>
|
||||
public M.Framework.WPF.ICustomCommand? AddCommand { get; set; }
|
||||
|
||||
public AddPropertyRowViewModel(Action<string> add) : base(string.Empty)
|
||||
{
|
||||
AddCommand = new M.Framework.WPF.Command((sender, e) =>
|
||||
{
|
||||
var key = KeyText.Trim();
|
||||
if (key.Length > 0)
|
||||
{
|
||||
add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>인스펙터 행 컨텍스트 — 대상 컨트롤 집합과 접근자</summary>
|
||||
public sealed class RowBinding
|
||||
{
|
||||
/// <summary>값 읽기</summary>
|
||||
public required Func<ControlViewModel, string?> Get { get; init; }
|
||||
|
||||
/// <summary>값 쓰기</summary>
|
||||
public required Action<ControlViewModel, string> Set { get; init; }
|
||||
|
||||
/// <summary>커밋 후 시각 재해석 필요 여부</summary>
|
||||
public bool AffectsVisual { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using M.Framework.WPF;
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Data.Stores;
|
||||
using SheetMe.Designer.DataBusiness;
|
||||
using SheetMe.Designer.Services;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 메인 셸 ViewModel — 멀티 문서 탭(여러 기록지 동시 편집), 상시 서식 목록 패널,
|
||||
/// 파일/DB 열기·저장, 도구(상용구/폰트). 컨트롤 클립보드는 문서 간 공유(서식 간 복사/붙여넣기).
|
||||
/// </summary>
|
||||
internal sealed class MainViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly FormDesignDataBusiness dataBusiness = new();
|
||||
private readonly DialogService dialogService = new();
|
||||
private DesignerViewModel? currentDesigner;
|
||||
private string title = "SheetMe 서식생성기";
|
||||
private string statusText = "준비";
|
||||
private string sheetSearchKeyword = string.Empty;
|
||||
private bool isSheetListLoading;
|
||||
private int selectedLeftTabIndex;
|
||||
private bool isHandTool;
|
||||
private const string XmlFilter = "서식 XML (*.xml)|*.xml|모든 파일 (*.*)|*.*";
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>창 제목</summary>
|
||||
public string Title
|
||||
{
|
||||
get => title;
|
||||
set => SetProperty(ref title, value);
|
||||
}
|
||||
|
||||
/// <summary>상태바 텍스트</summary>
|
||||
public string StatusText
|
||||
{
|
||||
get => statusText;
|
||||
set => SetProperty(ref statusText, value);
|
||||
}
|
||||
|
||||
/// <summary>열린 문서(탭) 목록</summary>
|
||||
public ObservableCollection<DesignerViewModel> OpenDesigners { get; } = new();
|
||||
|
||||
/// <summary>활성 문서(선택 탭)</summary>
|
||||
public DesignerViewModel? CurrentDesigner
|
||||
{
|
||||
get => currentDesigner;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref currentDesigner, value))
|
||||
{
|
||||
Title = value is null
|
||||
? "SheetMe 서식생성기"
|
||||
: $"SheetMe 서식생성기 — {value.DisplayName}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>팔레트 컨트롤 목록(레지스트리)</summary>
|
||||
public IReadOnlyList<ControlDescriptor> PaletteItems => ControlRegistry.All;
|
||||
|
||||
/// <summary>서식 목록(상시 패널) — DB E_ShtMst</summary>
|
||||
public ObservableCollection<SheetSummary> SheetList { get; } = new();
|
||||
|
||||
/// <summary>서식 목록 검색어</summary>
|
||||
public string SheetSearchKeyword
|
||||
{
|
||||
get => sheetSearchKeyword;
|
||||
set => SetProperty(ref sheetSearchKeyword, value);
|
||||
}
|
||||
|
||||
/// <summary>서식 목록 로딩 중</summary>
|
||||
public bool IsSheetListLoading
|
||||
{
|
||||
get => isSheetListLoading;
|
||||
set => SetProperty(ref isSheetListLoading, value);
|
||||
}
|
||||
|
||||
/// <summary>DB 사용 가능 여부(서식 목록 패널 안내용)</summary>
|
||||
public bool CanUseDb => dataBusiness.CanUseDb;
|
||||
|
||||
/// <summary>좌측 패널 탭(0=서식 목록, 1=레이어, 2=도구 상자) — 서식 열면 레이어로 자동 전환([200] 관행)</summary>
|
||||
public int SelectedLeftTabIndex
|
||||
{
|
||||
get => selectedLeftTabIndex;
|
||||
set => SetProperty(ref selectedLeftTabIndex, value);
|
||||
}
|
||||
|
||||
/// <summary>손(팬) 도구 활성 — 캔버스 드래그로 화면 이동(false=선택 도구, [200] 플로팅 바 관행)</summary>
|
||||
public bool IsHandTool
|
||||
{
|
||||
get => isHandTool;
|
||||
set => SetProperty(ref isHandTool, value);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public MainViewModel()
|
||||
{
|
||||
LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync());
|
||||
NewFileCommand = new Command((sender, e) => OnNewFile());
|
||||
OpenFileCommand = new Command((sender, e) => OnOpenFile());
|
||||
SaveFileCommand = new Command((sender, e) => OnSaveFile(saveAs: false));
|
||||
SaveAsFileCommand = new Command((sender, e) => OnSaveFile(saveAs: true));
|
||||
OpenFromDbCommand = new Command((sender, e) => OnOpenFromDb());
|
||||
SaveToDbCommand = new Command((sender, e) => OnSaveToDb());
|
||||
ExportJsonCommand = new Command((sender, e) => OnExportJson());
|
||||
ImportJsonCommand = new Command((sender, e) => OnImportJson());
|
||||
PreviewCommand = new Command((sender, e) => OnPreview());
|
||||
PrintCommand = new Command((sender, e) => OnPrint());
|
||||
RecordWordCommand = new Command((sender, e) => OnRecordWords());
|
||||
FontManagerCommand = new Command((sender, e) => OnFontManager());
|
||||
SheetHistoryCommand = new Command((sender, e) => OnSheetHistory());
|
||||
ZoomInCommand = new Command((sender, e) => CurrentDesigner?.ZoomIn());
|
||||
ZoomOutCommand = new Command((sender, e) => CurrentDesigner?.ZoomOut());
|
||||
ZoomResetCommand = new Command((sender, e) => CurrentDesigner?.ZoomReset());
|
||||
ExitCommand = new Command((sender, e) => Application.Current.Shutdown());
|
||||
SearchSheetsCommand = new Command(async (sender, e) => await LoadSheetListAsync());
|
||||
OpenSheetCommand = new Command((object param) => OnOpenSheetFromList(param as SheetSummary));
|
||||
CloseDocumentCommand = new Command((object param) => OnCloseDocument(param as DesignerViewModel));
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Commands
|
||||
/// <summary>창 로드</summary>
|
||||
public ICustomCommand? LoadedCommand { get; set; }
|
||||
|
||||
/// <summary>새 서식</summary>
|
||||
public ICustomCommand? NewFileCommand { get; set; }
|
||||
|
||||
/// <summary>서식 XML 열기</summary>
|
||||
public ICustomCommand? OpenFileCommand { get; set; }
|
||||
|
||||
/// <summary>저장</summary>
|
||||
public ICustomCommand? SaveFileCommand { get; set; }
|
||||
|
||||
/// <summary>다른 이름으로 저장</summary>
|
||||
public ICustomCommand? SaveAsFileCommand { get; set; }
|
||||
|
||||
/// <summary>DB에서 서식 열기(검색 대화상자)</summary>
|
||||
public ICustomCommand? OpenFromDbCommand { get; set; }
|
||||
|
||||
/// <summary>DB에 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — SaveMode 게이트</summary>
|
||||
public ICustomCommand? SaveToDbCommand { get; set; }
|
||||
|
||||
/// <summary>JSON 내보내기</summary>
|
||||
public ICustomCommand? ExportJsonCommand { get; set; }
|
||||
|
||||
/// <summary>JSON 가져오기</summary>
|
||||
public ICustomCommand? ImportJsonCommand { get; set; }
|
||||
|
||||
/// <summary>미리보기</summary>
|
||||
public ICustomCommand? PreviewCommand { get; set; }
|
||||
|
||||
/// <summary>인쇄</summary>
|
||||
public ICustomCommand? PrintCommand { get; set; }
|
||||
|
||||
/// <summary>상용구 관리</summary>
|
||||
public ICustomCommand? RecordWordCommand { get; set; }
|
||||
|
||||
/// <summary>폰트 일괄 변경</summary>
|
||||
public ICustomCommand? FontManagerCommand { get; set; }
|
||||
|
||||
/// <summary>서식 수정이력(버전 열람/복원)</summary>
|
||||
public ICustomCommand? SheetHistoryCommand { get; set; }
|
||||
|
||||
/// <summary>줌 확대</summary>
|
||||
public ICustomCommand? ZoomInCommand { get; set; }
|
||||
|
||||
/// <summary>줌 축소</summary>
|
||||
public ICustomCommand? ZoomOutCommand { get; set; }
|
||||
|
||||
/// <summary>줌 100%</summary>
|
||||
public ICustomCommand? ZoomResetCommand { get; set; }
|
||||
|
||||
/// <summary>종료</summary>
|
||||
public ICustomCommand? ExitCommand { get; set; }
|
||||
|
||||
/// <summary>서식 목록 검색</summary>
|
||||
public ICustomCommand? SearchSheetsCommand { get; set; }
|
||||
|
||||
/// <summary>서식 목록에서 열기(더블클릭)</summary>
|
||||
public ICustomCommand? OpenSheetCommand { get; set; }
|
||||
|
||||
/// <summary>문서 탭 닫기</summary>
|
||||
public ICustomCommand? CloseDocumentCommand { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Methods - 문서 탭
|
||||
/// <summary>문서를 탭으로 추가하고 활성화 — 좌측 패널은 레이어 탭으로 전환</summary>
|
||||
private void AttachDocument(DesignerViewModel designer)
|
||||
{
|
||||
OpenDesigners.Add(designer);
|
||||
CurrentDesigner = designer;
|
||||
SelectedLeftTabIndex = 1;
|
||||
}
|
||||
|
||||
private void OnCloseDocument(DesignerViewModel? designer)
|
||||
{
|
||||
designer ??= CurrentDesigner;
|
||||
if (designer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (designer.Undo.CanUndo
|
||||
&& MessageBox.Show($"'{designer.DisplayName}' 문서에 저장하지 않은 변경이 있을 수 있습니다.\n닫을까요?",
|
||||
"문서 닫기", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var index = OpenDesigners.IndexOf(designer);
|
||||
OpenDesigners.Remove(designer);
|
||||
if (CurrentDesigner == designer)
|
||||
{
|
||||
CurrentDesigner = OpenDesigners.Count > 0
|
||||
? OpenDesigners[Math.Clamp(index, 0, OpenDesigners.Count - 1)]
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
|
||||
private DesignerViewModel? FindOpenDbDocument(string shtCod)
|
||||
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod);
|
||||
#endregion
|
||||
|
||||
#region Methods - 열기/저장
|
||||
private async Task OnLoadedAsync()
|
||||
{
|
||||
OnNewFile();
|
||||
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
|
||||
await LoadSheetListAsync();
|
||||
}
|
||||
|
||||
private void OnNewFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
|
||||
StatusText = "새 서식 (720×856)";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = dialogService.ShowOpenFile(XmlFilter, "서식 XML 열기");
|
||||
if (path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var document = dataBusiness.OpenXmlFile(path);
|
||||
AttachDocument(new DesignerViewModel(document) { FilePath = path });
|
||||
StatusText = $"파일 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
|
||||
ShowReadWarnings(document.Meta.ReadWarnings, "서식 열기");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"서식을 여는 중 오류가 발생했습니다.\n\n{ex}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSaveFile(bool saveAs)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var path = CurrentDesigner.FilePath;
|
||||
if (saveAs || path is null)
|
||||
{
|
||||
path = dialogService.ShowSaveFile(XmlFilter, "서식 XML 저장",
|
||||
CurrentDesigner.Document.FormId + ".xml");
|
||||
if (path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dataBusiness.SaveXmlFile(CurrentDesigner.Document, path);
|
||||
CurrentDesigner.FilePath = path;
|
||||
CurrentDesigner.NotifyDisplayNameChanged();
|
||||
Title = $"SheetMe 서식생성기 — {CurrentDesigner.DisplayName}";
|
||||
StatusText = $"저장됨: {path}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"저장 중 오류가 발생했습니다.\n\n{ex}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenFromDb()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!EnsureDb())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var dialog = new Views.SheetOpenDialogView(keyword => dataBusiness.ListSheets(keyword))
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
};
|
||||
if (dialog.ShowDialog() != true || dialog.SelectedSheet is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
OpenDbSheet(dialog.SelectedSheet.ShtCod);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"DB에서 서식을 여는 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화</summary>
|
||||
private void OpenDbSheet(string shtCod)
|
||||
{
|
||||
var existing = FindOpenDbDocument(shtCod);
|
||||
if (existing is not null)
|
||||
{
|
||||
CurrentDesigner = existing;
|
||||
StatusText = $"이미 열린 서식: {shtCod}";
|
||||
return;
|
||||
}
|
||||
|
||||
var document = dataBusiness.OpenFromDb(shtCod);
|
||||
if (document is null)
|
||||
{
|
||||
MessageBox.Show("활성 디자인을 찾지 못했습니다.", "DB 열기",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
AttachDocument(new DesignerViewModel(document, dataBusiness.LoadSpreadGrids(shtCod)) { IsFromDb = true });
|
||||
StatusText = $"DB 로드: {document.FormId} (SdgKey {document.Meta.SourceSdgKey}) · " +
|
||||
$"페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
|
||||
ShowReadWarnings(document.Meta.ReadWarnings, "DB 열기");
|
||||
}
|
||||
|
||||
private void OnSaveToDb()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!dataBusiness.CanSaveToDb)
|
||||
{
|
||||
MessageBox.Show("DB 저장이 비활성화되어 있습니다.\nappsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정한 뒤 사용하세요.\n(운영 레거시 테이블에 기록되므로 명시적 활성화가 필요합니다)",
|
||||
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var document = CurrentDesigner.Document;
|
||||
|
||||
// 미등록 서식이면 신규 등록 다이얼로그
|
||||
if (!dataBusiness.SheetExists(document.FormId))
|
||||
{
|
||||
var register = new Views.RegisterSheetDialogView(
|
||||
document.FormId == "NewSheet" ? string.Empty : document.FormId,
|
||||
document.Title == "새 서식" ? string.Empty : document.Title)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
};
|
||||
if (register.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
dataBusiness.RegisterSheet(register.SheetCode, register.SheetName, register.ClassCode);
|
||||
document.FormId = register.SheetCode;
|
||||
document.Title = register.SheetName;
|
||||
}
|
||||
|
||||
var confirm = MessageBox.Show(
|
||||
$"서식 [{document.FormId}] {document.Title} 을(를) DB(E_SdgMst/E_SctMst)에 저장할까요?\n\n" +
|
||||
"기존 활성 디자인은 이력(SdgDelYon='Y')으로 보존되고 새 버전이 생성됩니다.\n" +
|
||||
"(제자리 갱신 서식(ShtCneYon='Y')은 기존 버전이 갱신됩니다)",
|
||||
"DB 저장 확인", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
if (confirm != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sdgKey = dataBusiness.SaveToDb(document);
|
||||
CurrentDesigner.IsFromDb = true;
|
||||
CurrentDesigner.NotifyDisplayNameChanged();
|
||||
StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}";
|
||||
MessageBox.Show($"저장되었습니다. (SdgKey {sdgKey})\n레거시 뷰어/디자이너에서 열어 확인하세요.", "DB 저장",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"DB 저장 중 오류가 발생했습니다. (트랜잭션 롤백됨)\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExportJson()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var path = dialogService.ShowSaveFile("서식 JSON (*.json)|*.json", "JSON 내보내기",
|
||||
CurrentDesigner.Document.FormId + ".json");
|
||||
if (path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var json = new Core.Serialization.FormJsonSerializer().Write(CurrentDesigner.Document);
|
||||
File.WriteAllText(path, json, System.Text.Encoding.UTF8);
|
||||
StatusText = $"JSON 내보내기 완료: {path}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"JSON 내보내기 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnImportJson()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = dialogService.ShowOpenFile("서식 JSON (*.json)|*.json", "JSON 가져오기");
|
||||
if (path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var document = new Core.Serialization.FormJsonSerializer().Read(File.ReadAllText(path));
|
||||
if (document.FormId.Length == 0)
|
||||
{
|
||||
document.FormId = Path.GetFileNameWithoutExtension(path);
|
||||
}
|
||||
AttachDocument(new DesignerViewModel(document));
|
||||
StatusText = $"JSON 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"JSON 가져오기 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - 서식 목록 패널
|
||||
/// <summary>서식 목록 로드(비동기) — DB 미접속이면 건너뜀</summary>
|
||||
private async Task LoadSheetListAsync()
|
||||
{
|
||||
if (!dataBusiness.CanUseDb || IsSheetListLoading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
IsSheetListLoading = true;
|
||||
var keyword = SheetSearchKeyword;
|
||||
var sheets = await Task.Run(() => dataBusiness.ListSheets(keyword));
|
||||
SheetList.Clear();
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
SheetList.Add(sheet);
|
||||
}
|
||||
StatusText = $"서식 목록 {sheets.Count}건";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"서식 목록 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "서식 목록",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSheetListLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서식 목록에서 열기 — 뷰 더블클릭 핸들러가 위임 호출</summary>
|
||||
public void OpenSheetFromList(SheetSummary? sheet) => OnOpenSheetFromList(sheet);
|
||||
|
||||
private void OnOpenSheetFromList(SheetSummary? sheet)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (sheet is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!sheet.HasDesign)
|
||||
{
|
||||
MessageBox.Show("선택한 서식에는 저장된 디자인이 없습니다.", "서식 열기",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
OpenDbSheet(sheet.ShtCod);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"서식을 여는 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - 도구
|
||||
private void OnPreview()
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
new Views.PreviewWindow(CurrentDesigner)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
}.Show();
|
||||
}
|
||||
|
||||
private void OnPrint()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Services.PrintService.Print(CurrentDesigner, CurrentDesigner.Document.Title.Length > 0
|
||||
? CurrentDesigner.Document.Title
|
||||
: CurrentDesigner.Document.FormId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"인쇄 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFontManager()
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
new Views.FontManagerDialogView(CurrentDesigner)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
}.ShowDialog();
|
||||
}
|
||||
|
||||
private void OnSheetHistory()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null || !EnsureDb())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var document = CurrentDesigner.Document;
|
||||
if (document.FormId.Length == 0 || document.FormId == "NewSheet")
|
||||
{
|
||||
MessageBox.Show("수정이력은 DB에 저장된 서식에서 사용할 수 있습니다.", "서식 수정이력",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var versions = dataBusiness.ListVersions(document.FormId);
|
||||
if (versions.Count == 0)
|
||||
{
|
||||
MessageBox.Show("저장된 버전이 없습니다.", "서식 수정이력",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = new Views.SheetHistoryDialogView(document.FormId, document.Title, versions)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
};
|
||||
if (dialog.ShowDialog() != true || dialog.SelectedSdgKey is not { } sdgKey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 열람 중인 동일 버전 탭이면 활성화
|
||||
var existing = OpenDesigners.FirstOrDefault(d => d.HistorySdgKey == sdgKey
|
||||
&& d.Document.FormId == document.FormId);
|
||||
if (existing is not null)
|
||||
{
|
||||
CurrentDesigner = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
var versionDocument = dataBusiness.OpenFromDbVersion(document.FormId, sdgKey);
|
||||
if (versionDocument is null)
|
||||
{
|
||||
MessageBox.Show("해당 버전을 불러오지 못했습니다.", "서식 수정이력",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
AttachDocument(new DesignerViewModel(versionDocument, dataBusiness.LoadSpreadGrids(document.FormId))
|
||||
{
|
||||
IsFromDb = true,
|
||||
HistorySdgKey = sdgKey,
|
||||
});
|
||||
StatusText = $"이력 열람: {document.FormId} SdgKey {sdgKey} — 'DB에 저장' 시 이 내용이 새 활성 버전이 됩니다";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"수정이력 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRecordWords()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!EnsureDb())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var document = CurrentDesigner.Document;
|
||||
if (document.FormId.Length == 0 || document.FormId == "NewSheet")
|
||||
{
|
||||
MessageBox.Show("상용구는 서식 코드 단위로 저장됩니다.\n먼저 DB에 저장(서식 등록)한 뒤 사용하세요.",
|
||||
"상용구 관리", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
new Views.RecordWordDialogView(dataBusiness.RecordWords(), document.FormId, document.Title)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
}.ShowDialog();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"상용구 관리 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Private
|
||||
private bool EnsureDb()
|
||||
{
|
||||
if (dataBusiness.CanUseDb)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
MessageBox.Show("DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.\nappsettings.json 을 확인하세요.",
|
||||
"DB 연결", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ShowReadWarnings(List<string> warnings, string caption)
|
||||
{
|
||||
if (warnings.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var summary = string.Join("\n", warnings.Take(20));
|
||||
var more = warnings.Count > 20 ? $"\n... 외 {warnings.Count - 20}건" : string.Empty;
|
||||
MessageBox.Show($"읽기 경고 {warnings.Count}건 (미지원 컨트롤은 자리표시로 보존됩니다):\n\n{summary}{more}",
|
||||
caption, MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
|
||||
private static int CountControls(List<Core.Models.ControlElement> controls)
|
||||
=> controls.Sum(c => 1 + CountControls(c.Children));
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>페이지 ViewModel — 용지 1장의 크기·배경·컨트롤(그리기 순서) 투영.</summary>
|
||||
public sealed class PageViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private double offsetY;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>페이지 모델</summary>
|
||||
public FormPage Model { get; }
|
||||
|
||||
/// <summary>페이지 번호(0-base)</summary>
|
||||
public int Index { get; }
|
||||
|
||||
/// <summary>페이지 컨트롤(그리기 순서 — 마지막이 최상위)</summary>
|
||||
public ObservableCollection<ControlViewModel> Controls { get; } = new();
|
||||
|
||||
/// <summary>용지 너비(DIP)</summary>
|
||||
public double WidthDip => Model.Width;
|
||||
|
||||
/// <summary>용지 높이(DIP)</summary>
|
||||
public double HeightDip => Model.Height;
|
||||
|
||||
/// <summary>월드 좌표 세로 오프셋 — DesignerViewModel 이 페이지 스택 계산</summary>
|
||||
public double OffsetY
|
||||
{
|
||||
get => offsetY;
|
||||
set => SetProperty(ref offsetY, value);
|
||||
}
|
||||
|
||||
/// <summary>용지 배경 브러시 — 루트 BackColor(기본 White)</summary>
|
||||
public Brush PaperBrush
|
||||
{
|
||||
get
|
||||
{
|
||||
var backColor = Model.Root.Props.GetText("BackColor");
|
||||
if (backColor is null)
|
||||
{
|
||||
return Brushes.White;
|
||||
}
|
||||
var (a, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(backColor);
|
||||
var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PageViewModel(FormPage model, int index)
|
||||
{
|
||||
Model = model;
|
||||
Index = index;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>용지 크기 변경 통지</summary>
|
||||
public void NotifySizeChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(WidthDip));
|
||||
OnPropertyChanged(nameof(HeightDip));
|
||||
OnPropertyChanged(nameof(SizeText));
|
||||
}
|
||||
|
||||
/// <summary>페이지 패널 표시 텍스트</summary>
|
||||
public string SizeText => $"{WidthDip:0}×{HeightDip:0}";
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>오버레이 가이드선 1개 — 월드 좌표 수직/수평선</summary>
|
||||
public sealed class GuideLineInfo
|
||||
{
|
||||
/// <summary>수직선 여부(false=수평선)</summary>
|
||||
public bool IsVertical { get; init; }
|
||||
|
||||
/// <summary>선 위치(월드 X 또는 Y)</summary>
|
||||
public double Position { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
|
||||
public sealed class TabBadgeInfo
|
||||
{
|
||||
/// <summary>배지 X(월드)</summary>
|
||||
public double X { get; init; }
|
||||
|
||||
/// <summary>배지 Y(월드)</summary>
|
||||
public double Y { get; init; }
|
||||
|
||||
/// <summary>표시 텍스트(순번 또는 "–")</summary>
|
||||
public string Text { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>순번 지정 여부(지정=파랑, 미지정=회색)</summary>
|
||||
public bool IsAssigned { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>선택 핸들 1개 — 월드 좌표(중심점)</summary>
|
||||
public sealed class HandleInfo
|
||||
{
|
||||
/// <summary>핸들 인덱스(0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)</summary>
|
||||
public int Index { get; init; }
|
||||
|
||||
/// <summary>핸들 좌상단 X(월드)</summary>
|
||||
public double X { get; init; }
|
||||
|
||||
/// <summary>핸들 좌상단 Y(월드)</summary>
|
||||
public double Y { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택/마퀴/가이드 오버레이 표시 상태 — 전부 월드 좌표.
|
||||
/// 오버레이 뷰는 이 상태를 그리기만 한다(입력 처리 없음).
|
||||
/// </summary>
|
||||
public sealed class SelectionOverlayViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>핸들 한 변 크기(논리 px)</summary>
|
||||
public const double HandleSize = 8;
|
||||
|
||||
private bool hasSelection;
|
||||
private double selX;
|
||||
private double selY;
|
||||
private double selW;
|
||||
private double selH;
|
||||
private bool showHandles;
|
||||
private bool hasMarquee;
|
||||
private double marX;
|
||||
private double marY;
|
||||
private double marW;
|
||||
private double marH;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택 박스 표시 여부</summary>
|
||||
public bool HasSelection { get => hasSelection; set => SetProperty(ref hasSelection, value); }
|
||||
|
||||
/// <summary>선택 박스 X(월드)</summary>
|
||||
public double SelX { get => selX; set => SetProperty(ref selX, value); }
|
||||
|
||||
/// <summary>선택 박스 Y(월드)</summary>
|
||||
public double SelY { get => selY; set => SetProperty(ref selY, value); }
|
||||
|
||||
/// <summary>선택 박스 너비</summary>
|
||||
public double SelW { get => selW; set => SetProperty(ref selW, value); }
|
||||
|
||||
/// <summary>선택 박스 높이</summary>
|
||||
public double SelH { get => selH; set => SetProperty(ref selH, value); }
|
||||
|
||||
/// <summary>리사이즈 핸들 표시 여부(잠금 선택 시 숨김)</summary>
|
||||
public bool ShowHandles { get => showHandles; set => SetProperty(ref showHandles, value); }
|
||||
|
||||
/// <summary>핸들 8개(월드 좌표)</summary>
|
||||
public ObservableCollection<HandleInfo> Handles { get; } = new();
|
||||
|
||||
/// <summary>마퀴 표시 여부</summary>
|
||||
public bool HasMarquee { get => hasMarquee; set => SetProperty(ref hasMarquee, value); }
|
||||
|
||||
/// <summary>마퀴 X(월드)</summary>
|
||||
public double MarX { get => marX; set => SetProperty(ref marX, value); }
|
||||
|
||||
/// <summary>마퀴 Y(월드)</summary>
|
||||
public double MarY { get => marY; set => SetProperty(ref marY, value); }
|
||||
|
||||
/// <summary>마퀴 너비</summary>
|
||||
public double MarW { get => marW; set => SetProperty(ref marW, value); }
|
||||
|
||||
/// <summary>마퀴 높이</summary>
|
||||
public double MarH { get => marH; set => SetProperty(ref marH, value); }
|
||||
|
||||
/// <summary>정렬 가이드선 목록</summary>
|
||||
public ObservableCollection<GuideLineInfo> Guides { get; } = new();
|
||||
|
||||
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
|
||||
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>선택 박스/핸들 갱신 — bbox 는 월드 좌표</summary>
|
||||
public void UpdateSelection(Rect? bbox, bool handlesVisible)
|
||||
{
|
||||
if (bbox is null || bbox.Value.IsEmpty)
|
||||
{
|
||||
HasSelection = false;
|
||||
ShowHandles = false;
|
||||
Handles.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = bbox.Value;
|
||||
HasSelection = true;
|
||||
SelX = rect.X;
|
||||
SelY = rect.Y;
|
||||
SelW = rect.Width;
|
||||
SelH = rect.Height;
|
||||
ShowHandles = handlesVisible;
|
||||
|
||||
Handles.Clear();
|
||||
if (!handlesVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var half = HandleSize / 2;
|
||||
var positions = HandlePositions(rect);
|
||||
for (var i = 0; i < positions.Length; i++)
|
||||
{
|
||||
Handles.Add(new HandleInfo { Index = i, X = positions[i].X - half, Y = positions[i].Y - half });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>핸들 중심 좌표 8개(0=NW 시계방향)</summary>
|
||||
public static Point[] HandlePositions(Rect rect) => new[]
|
||||
{
|
||||
new Point(rect.Left, rect.Top),
|
||||
new Point(rect.Left + rect.Width / 2, rect.Top),
|
||||
new Point(rect.Right, rect.Top),
|
||||
new Point(rect.Right, rect.Top + rect.Height / 2),
|
||||
new Point(rect.Right, rect.Bottom),
|
||||
new Point(rect.Left + rect.Width / 2, rect.Bottom),
|
||||
new Point(rect.Left, rect.Bottom),
|
||||
new Point(rect.Left, rect.Top + rect.Height / 2),
|
||||
};
|
||||
|
||||
/// <summary>마퀴 갱신 — null 이면 숨김</summary>
|
||||
public void UpdateMarquee(Rect? rect)
|
||||
{
|
||||
if (rect is null)
|
||||
{
|
||||
HasMarquee = false;
|
||||
return;
|
||||
}
|
||||
HasMarquee = true;
|
||||
MarX = rect.Value.X;
|
||||
MarY = rect.Value.Y;
|
||||
MarW = rect.Value.Width;
|
||||
MarH = rect.Value.Height;
|
||||
}
|
||||
|
||||
/// <summary>가이드선 교체</summary>
|
||||
public void SetGuides(double? guideX, double? guideY)
|
||||
{
|
||||
Guides.Clear();
|
||||
if (guideX is not null)
|
||||
{
|
||||
Guides.Add(new GuideLineInfo { IsVertical = true, Position = guideX.Value });
|
||||
}
|
||||
if (guideY is not null)
|
||||
{
|
||||
Guides.Add(new GuideLineInfo { IsVertical = false, Position = guideY.Value });
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using M.Framework.WPF;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>프로젝트 공통 ViewModel 베이스 — BindableBase + 비동기 정리 지원.</summary>
|
||||
public abstract class ViewModelBase : BindableBase, IAsyncDisposable
|
||||
{
|
||||
#region Dispose
|
||||
/// <summary>비동기 정리</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisposeAsyncCore();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>파생 클래스 정리 지점</summary>
|
||||
protected virtual ValueTask DisposeAsyncCore() => ValueTask.CompletedTask;
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Figma 식 색상 피커([200]SheetMe ColorPickerWindow 이식) — 채도/명도(SV) 사각형 + 색상(Hue) 슬라이더
|
||||
/// + HEX·RGB 입력 + 프리셋/최근색. 정적 <see cref="Pick"/> 호출: 확정 시 "#RRGGBB" 반환, 취소 시 null.
|
||||
/// 셸 색은 앱 테마 토큰(B.*) — SV/Hue/스와치는 색상 원본 그대로.
|
||||
/// </summary>
|
||||
public sealed class ColorPickerWindow : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly List<string> recent = new();
|
||||
|
||||
private double hue360; // H 0..360
|
||||
private double sat; // S 0..1
|
||||
private double val; // V 0..1
|
||||
private bool syncing; // 텍스트박스 ↔ 상태 갱신 재진입 방지
|
||||
|
||||
private readonly Canvas svArea = new() { Width = 248, Height = 168, ClipToBounds = true, Cursor = Cursors.Cross };
|
||||
private readonly Rectangle svHue = new() { Width = 248, Height = 168 };
|
||||
private readonly Ellipse svThumb = new() { Width = 14, Height = 14, Stroke = Brushes.White, StrokeThickness = 2, IsHitTestVisible = false };
|
||||
private readonly Canvas hueArea = new() { Width = 248, Height = 16, ClipToBounds = true, Cursor = Cursors.Cross };
|
||||
private readonly Border hueThumb = new() { Width = 6, Height = 20, BorderBrush = Brushes.White, BorderThickness = new Thickness(2), CornerRadius = new CornerRadius(2), IsHitTestVisible = false };
|
||||
private readonly Border preview = new() { Width = 40, Height = 40, CornerRadius = new CornerRadius(6), BorderThickness = new Thickness(1) };
|
||||
private readonly TextBox hexBox = new() { Width = 92, VerticalContentAlignment = VerticalAlignment.Center };
|
||||
private readonly TextBox rBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
|
||||
private readonly TextBox gBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
|
||||
private readonly TextBox bBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>확정된 "#RRGGBB"(취소 시 null)</summary>
|
||||
public string? Result { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>색상 선택 대화상자 — 확정 시 "#RRGGBB", 취소 시 null. initialHex 로 초기색 지정.</summary>
|
||||
public static string? Pick(Window? owner, string? initialHex)
|
||||
{
|
||||
var window = new ColorPickerWindow(initialHex);
|
||||
if (owner is not null)
|
||||
{
|
||||
window.Owner = owner;
|
||||
}
|
||||
return window.ShowDialog() == true ? window.Result : null;
|
||||
}
|
||||
|
||||
private ColorPickerWindow(string? initialHex)
|
||||
{
|
||||
Title = "색상 선택";
|
||||
Width = 300;
|
||||
SizeToContent = SizeToContent.Height;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
ResizeMode = ResizeMode.NoResize;
|
||||
ShowInTaskbar = false;
|
||||
FontSize = 12.5;
|
||||
|
||||
var line = FindBrush("B.Line");
|
||||
var muted = FindBrush("B.Muted");
|
||||
preview.BorderBrush = line;
|
||||
|
||||
var initial = ParseHex(initialHex) ?? Color.FromRgb(0x2F, 0x6D, 0xF0);
|
||||
(hue360, sat, val) = RgbToHsv(initial.R, initial.G, initial.B);
|
||||
|
||||
var root = new StackPanel { Margin = new Thickness(14) };
|
||||
|
||||
// ── SV 사각형(흰색→hue 가로 + 투명→검정 세로) ──
|
||||
svArea.Children.Add(svHue);
|
||||
svArea.Children.Add(new Rectangle
|
||||
{
|
||||
Width = 248, Height = 168,
|
||||
Fill = new LinearGradientBrush(Color.FromArgb(255, 255, 255, 255), Color.FromArgb(0, 255, 255, 255), new Point(0, 0), new Point(1, 0)),
|
||||
});
|
||||
svArea.Children.Add(new Rectangle
|
||||
{
|
||||
Width = 248, Height = 168,
|
||||
Fill = new LinearGradientBrush(Color.FromArgb(0, 0, 0, 0), Color.FromArgb(255, 0, 0, 0), new Point(0, 0), new Point(0, 1)),
|
||||
});
|
||||
svArea.Children.Add(svThumb);
|
||||
svArea.MouseLeftButtonDown += (_, e) => { svArea.CaptureMouse(); UpdateSvFrom(e.GetPosition(svArea)); };
|
||||
svArea.MouseMove += (_, e) => { if (e.LeftButton == MouseButtonState.Pressed && svArea.IsMouseCaptured) UpdateSvFrom(e.GetPosition(svArea)); };
|
||||
svArea.MouseLeftButtonUp += (_, _) => svArea.ReleaseMouseCapture();
|
||||
root.Children.Add(new Border { Child = svArea, CornerRadius = new CornerRadius(6), ClipToBounds = true, Margin = new Thickness(0, 0, 0, 10) });
|
||||
|
||||
// ── Hue 슬라이더(무지개) ──
|
||||
hueArea.Children.Add(new Rectangle
|
||||
{
|
||||
Width = 248, Height = 16,
|
||||
Fill = new LinearGradientBrush(new GradientStopCollection
|
||||
{
|
||||
new(Color.FromRgb(255, 0, 0), 0), new(Color.FromRgb(255, 255, 0), 1 / 6.0), new(Color.FromRgb(0, 255, 0), 2 / 6.0),
|
||||
new(Color.FromRgb(0, 255, 255), 3 / 6.0), new(Color.FromRgb(0, 0, 255), 4 / 6.0), new(Color.FromRgb(255, 0, 255), 5 / 6.0), new(Color.FromRgb(255, 0, 0), 1),
|
||||
}, new Point(0, 0), new Point(1, 0)),
|
||||
RadiusX = 8, RadiusY = 8,
|
||||
});
|
||||
hueArea.Children.Add(hueThumb);
|
||||
Canvas.SetTop(hueThumb, -2);
|
||||
hueArea.MouseLeftButtonDown += (_, e) => { hueArea.CaptureMouse(); UpdateHueFrom(e.GetPosition(hueArea)); };
|
||||
hueArea.MouseMove += (_, e) => { if (e.LeftButton == MouseButtonState.Pressed && hueArea.IsMouseCaptured) UpdateHueFrom(e.GetPosition(hueArea)); };
|
||||
hueArea.MouseLeftButtonUp += (_, _) => hueArea.ReleaseMouseCapture();
|
||||
root.Children.Add(new Border { Child = hueArea, Margin = new Thickness(0, 0, 0, 12) });
|
||||
|
||||
// ── 미리보기 + HEX / RGB ──
|
||||
var inputs = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 12) };
|
||||
inputs.Children.Add(preview);
|
||||
var fields = new StackPanel { Margin = new Thickness(12, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
|
||||
var hexRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
|
||||
hexRow.Children.Add(new TextBlock { Text = "HEX", Width = 30, VerticalAlignment = VerticalAlignment.Center, Foreground = muted });
|
||||
hexRow.Children.Add(hexBox);
|
||||
fields.Children.Add(hexRow);
|
||||
var rgbRow = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
rgbRow.Children.Add(new TextBlock { Text = "RGB", Width = 30, VerticalAlignment = VerticalAlignment.Center, Foreground = muted });
|
||||
rgbRow.Children.Add(rBox);
|
||||
rgbRow.Children.Add(new Border { Width = 4 });
|
||||
rgbRow.Children.Add(gBox);
|
||||
rgbRow.Children.Add(new Border { Width = 4 });
|
||||
rgbRow.Children.Add(bBox);
|
||||
fields.Children.Add(rgbRow);
|
||||
inputs.Children.Add(fields);
|
||||
root.Children.Add(inputs);
|
||||
|
||||
hexBox.LostKeyboardFocus += (_, _) => CommitHex();
|
||||
hexBox.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitHex(); };
|
||||
foreach (var box in new[] { rBox, gBox, bBox })
|
||||
{
|
||||
box.LostKeyboardFocus += (_, _) => CommitRgb();
|
||||
box.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitRgb(); };
|
||||
}
|
||||
|
||||
// ── 프리셋 + 최근색 ──
|
||||
root.Children.Add(SwatchGrid("프리셋", new[]
|
||||
{
|
||||
"#000000", "#374151", "#6B727B", "#C7CDD4", "#FFFFFF", "#C0392B", "#E67E22", "#F1C40F",
|
||||
"#1F9D55", "#16A085", "#2F6DF0", "#2980B9", "#8E44AD", "#EC4899", "#7F1D1D", "#FDE68A",
|
||||
}));
|
||||
if (recent.Count > 0)
|
||||
{
|
||||
root.Children.Add(SwatchGrid("최근 사용", recent.ToArray()));
|
||||
}
|
||||
|
||||
// ── 버튼 ──
|
||||
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
|
||||
var ok = new Button { Content = "적용", MinWidth = 64, Margin = new Thickness(0, 0, 8, 0), IsDefault = true };
|
||||
try { ok.Style = (Style)FindResource("Primary"); } catch { /* 테마 미로드 시 기본 */ }
|
||||
ok.Click += (_, _) => { Result = CurrentHex(); AddRecent(Result); DialogResult = true; };
|
||||
var cancel = new Button { Content = "취소", MinWidth = 64, IsCancel = true };
|
||||
cancel.Click += (_, _) => DialogResult = false;
|
||||
buttons.Children.Add(ok);
|
||||
buttons.Children.Add(cancel);
|
||||
root.Children.Add(buttons);
|
||||
|
||||
Content = root;
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private FrameworkElement SwatchGrid(string title, string[] hexes)
|
||||
{
|
||||
var section = new StackPanel { Margin = new Thickness(0, 2, 0, 0) };
|
||||
section.Children.Add(new TextBlock { Text = title, FontSize = 11, Foreground = FindBrush("B.Muted"), Margin = new Thickness(0, 0, 0, 4) });
|
||||
var wrap = new WrapPanel();
|
||||
foreach (var hex in hexes)
|
||||
{
|
||||
var swatch = new Button
|
||||
{
|
||||
Width = 22, Height = 22, Margin = new Thickness(0, 0, 4, 4), Padding = new Thickness(0),
|
||||
ToolTip = hex, BorderBrush = FindBrush("B.Line2"),
|
||||
Background = new SolidColorBrush(ParseHex(hex) ?? Colors.White),
|
||||
};
|
||||
var captured = hex;
|
||||
swatch.Click += (_, _) =>
|
||||
{
|
||||
if (ParseHex(captured) is not { } color)
|
||||
{
|
||||
return;
|
||||
}
|
||||
(hue360, sat, val) = RgbToHsv(color.R, color.G, color.B);
|
||||
RefreshAll();
|
||||
};
|
||||
wrap.Children.Add(swatch);
|
||||
}
|
||||
section.Children.Add(wrap);
|
||||
return section;
|
||||
}
|
||||
|
||||
private static Brush FindBrush(string key)
|
||||
=> Application.Current.TryFindResource(key) as Brush ?? Brushes.Gray;
|
||||
|
||||
// ── 상호작용 → 상태 ──
|
||||
private void UpdateSvFrom(Point point)
|
||||
{
|
||||
sat = Math.Clamp(point.X / svArea.Width, 0, 1);
|
||||
val = 1 - Math.Clamp(point.Y / svArea.Height, 0, 1);
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private void UpdateHueFrom(Point point)
|
||||
{
|
||||
hue360 = Math.Clamp(point.X / hueArea.Width, 0, 1) * 360;
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private void CommitHex()
|
||||
{
|
||||
if (syncing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (ParseHex(hexBox.Text) is not { } color)
|
||||
{
|
||||
RefreshAll();
|
||||
return;
|
||||
}
|
||||
(hue360, sat, val) = RgbToHsv(color.R, color.G, color.B);
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private void CommitRgb()
|
||||
{
|
||||
if (syncing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (byte.TryParse(rBox.Text, out var r) && byte.TryParse(gBox.Text, out var g) && byte.TryParse(bBox.Text, out var b))
|
||||
{
|
||||
(hue360, sat, val) = RgbToHsv(r, g, b);
|
||||
}
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
// ── 상태 → 화면 ──
|
||||
private void RefreshAll()
|
||||
{
|
||||
var (hueR, hueG, hueB) = HsvToRgb(hue360, 1, 1);
|
||||
svHue.Fill = new SolidColorBrush(Color.FromRgb(hueR, hueG, hueB));
|
||||
Canvas.SetLeft(svThumb, sat * svArea.Width - 7);
|
||||
Canvas.SetTop(svThumb, (1 - val) * svArea.Height - 7);
|
||||
var (r, g, b) = HsvToRgb(hue360, sat, val);
|
||||
svThumb.Fill = new SolidColorBrush(Color.FromRgb(r, g, b));
|
||||
Canvas.SetLeft(hueThumb, hue360 / 360 * hueArea.Width - 3);
|
||||
|
||||
preview.Background = new SolidColorBrush(Color.FromRgb(r, g, b));
|
||||
|
||||
syncing = true;
|
||||
hexBox.Text = CurrentHex();
|
||||
rBox.Text = r.ToString();
|
||||
gBox.Text = g.ToString();
|
||||
bBox.Text = b.ToString();
|
||||
syncing = false;
|
||||
}
|
||||
|
||||
private string CurrentHex()
|
||||
{
|
||||
var (r, g, b) = HsvToRgb(hue360, sat, val);
|
||||
return $"#{r:X2}{g:X2}{b:X2}";
|
||||
}
|
||||
|
||||
private static void AddRecent(string? hex)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hex))
|
||||
{
|
||||
return;
|
||||
}
|
||||
recent.Remove(hex);
|
||||
recent.Insert(0, hex);
|
||||
while (recent.Count > 16)
|
||||
{
|
||||
recent.RemoveAt(recent.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 색 변환 ──
|
||||
private static Color? ParseHex(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var s = text.Trim().TrimStart('#');
|
||||
if (s.Length == 3)
|
||||
{
|
||||
s = $"{s[0]}{s[0]}{s[1]}{s[1]}{s[2]}{s[2]}";
|
||||
}
|
||||
if (s.Length != 6)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return int.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
|
||||
? Color.FromRgb((byte)((value >> 16) & 0xFF), (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF))
|
||||
: null;
|
||||
}
|
||||
|
||||
private static (double H, double S, double V) RgbToHsv(byte r, byte g, byte b)
|
||||
{
|
||||
double rd = r / 255.0, gd = g / 255.0, bd = b / 255.0;
|
||||
double max = Math.Max(rd, Math.Max(gd, bd)), min = Math.Min(rd, Math.Min(gd, bd));
|
||||
double d = max - min, h = 0;
|
||||
if (d != 0)
|
||||
{
|
||||
if (max == rd)
|
||||
{
|
||||
h = 60 * (((gd - bd) / d) % 6);
|
||||
}
|
||||
else if (max == gd)
|
||||
{
|
||||
h = 60 * (((bd - rd) / d) + 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
h = 60 * (((rd - gd) / d) + 4);
|
||||
}
|
||||
}
|
||||
if (h < 0)
|
||||
{
|
||||
h += 360;
|
||||
}
|
||||
return (h, max == 0 ? 0 : d / max, max);
|
||||
}
|
||||
|
||||
private static (byte R, byte G, byte B) HsvToRgb(double h, double s, double v)
|
||||
{
|
||||
double c = v * s, x = c * (1 - Math.Abs((h / 60 % 2) - 1)), m = v - c;
|
||||
double r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; }
|
||||
else if (h < 120) { r = x; g = c; }
|
||||
else if (h < 180) { g = c; b = x; }
|
||||
else if (h < 240) { g = x; b = c; }
|
||||
else if (h < 300) { r = x; b = c; }
|
||||
else { r = c; b = x; }
|
||||
return ((byte)Math.Round((r + m) * 255), (byte)Math.Round((g + m) * 255), (byte)Math.Round((b + m) * 255));
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<UserControl x:Class="SheetMe.Designer.Views.DesignerCanvasView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
|
||||
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
||||
xmlns:v="clr-namespace:SheetMe.Designer.Views"
|
||||
xmlns:bh="clr-namespace:SheetMe.Designer.Behaviors"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance vm:DesignerViewModel}"
|
||||
Focusable="True" FocusVisualStyle="{x:Null}"
|
||||
PreviewKeyDown="OnCanvasPreviewKeyDown" PreviewKeyUp="OnCanvasPreviewKeyUp">
|
||||
|
||||
<!-- 키보드 단축키(텍스트 입력 보호 포함) -->
|
||||
<b:Interaction.Behaviors>
|
||||
<bh:CanvasKeyboardBehavior/>
|
||||
</b:Interaction.Behaviors>
|
||||
|
||||
<!-- 디자인 캔버스: 스크롤 + 줌(LayoutTransform) 월드. 입력은 World 1곳에서 수신 -->
|
||||
<ScrollViewer x:Name="Scroll"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Background="{DynamicResource B.CanvasBg}"
|
||||
Focusable="False"
|
||||
PreviewMouseWheel="OnPreviewMouseWheel"
|
||||
PreviewMouseLeftButtonDown="OnPanMouseDown"
|
||||
PreviewMouseMove="OnPanMouseMove"
|
||||
PreviewMouseLeftButtonUp="OnPanMouseUp"
|
||||
LostMouseCapture="OnPanLostCapture">
|
||||
<Border Padding="24">
|
||||
<Grid x:Name="World"
|
||||
Background="Transparent"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
Width="{Binding WorldWidth}" Height="{Binding WorldHeight}">
|
||||
<Grid.LayoutTransform>
|
||||
<ScaleTransform ScaleX="{Binding Zoom}" ScaleY="{Binding Zoom}"/>
|
||||
</Grid.LayoutTransform>
|
||||
|
||||
<b:Interaction.Behaviors>
|
||||
<bh:CanvasMouseBehavior/>
|
||||
<bh:CanvasDropBehavior/>
|
||||
</b:Interaction.Behaviors>
|
||||
|
||||
<!-- 층1: 페이지 스택 -->
|
||||
<ItemsControl ItemsSource="{Binding Pages}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="0"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding OffsetY}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<v:PageView/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 층2: 선택/마퀴/가이드 오버레이 (표시 전용) -->
|
||||
<v:SelectionOverlayView DataContext="{Binding Overlay}"/>
|
||||
|
||||
<!-- 층3: 인라인 텍스트 에디터 (더블클릭 시 코드비하인드가 배치) -->
|
||||
<Canvas x:Name="EditorLayer"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.FontManagerDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="폰트 일괄 변경" Width="620" Height="560" MinWidth="520" MinHeight="420"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
|
||||
Text="문서 전체의 (타입·글꼴·크기) 조합입니다. 변경할 조합을 선택(다중 가능)하고 새 글꼴을 지정하세요."/>
|
||||
|
||||
<!-- 새 글꼴 지정 + 적용 -->
|
||||
<Border DockPanel.Dock="Bottom" Background="{DynamicResource B.Chip}" CornerRadius="4" Padding="10" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<DockPanel>
|
||||
<TextBlock Text="새 글꼴" Width="60" VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="FamilyBox" IsEditable="True" Width="180" HorizontalAlignment="Left"/>
|
||||
<TextBlock Text="크기(pt)" Margin="14,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="SizeBox" Width="50" Text="9" VerticalContentAlignment="Center"/>
|
||||
<CheckBox x:Name="BoldBox" Content="굵게" Margin="14,0,0,0" VerticalAlignment="Center"/>
|
||||
<CheckBox x:Name="ItalicBox" Content="기울임" Margin="10,0,0,0" VerticalAlignment="Center"/>
|
||||
<CheckBox x:Name="UnderlineBox" Content="밑줄" Margin="10,0,0,0" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,10,0,0">
|
||||
<Button DockPanel.Dock="Right" Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
<Button DockPanel.Dock="Right" Content="전체에 적용" Padding="14,4" Margin="8,0,0,0" Click="OnApplyAll"/>
|
||||
<Button DockPanel.Dock="Right" Content="선택 조합에 적용" Padding="14,4" Click="OnApplySelected"/>
|
||||
<TextBlock x:Name="ResultText" VerticalAlignment="Center" Foreground="{DynamicResource B.Success}"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 집계 목록 -->
|
||||
<ListBox x:Name="GroupList" SelectionMode="Extended" FontSize="13"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Display}" Margin="2"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 폰트 일괄 변경 대화상자 — 레거시 frmFontManager 이식.
|
||||
/// 문서 전체 (타입·글꼴·크기·스타일) 집계 → 선택 조합 또는 전체에 새 글꼴 일괄 적용(Undo 1스텝).
|
||||
/// </summary>
|
||||
public partial class FontManagerDialogView : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly DesignerViewModel designer;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public FontManagerDialogView(DesignerViewModel designer)
|
||||
{
|
||||
this.designer = designer;
|
||||
InitializeComponent();
|
||||
FamilyBox.ItemsSource = Fonts.SystemFontFamilies
|
||||
.Select(f => f.FamilyNames.Values.FirstOrDefault() ?? f.Source)
|
||||
.Distinct()
|
||||
.OrderBy(name => name, StringComparer.CurrentCulture)
|
||||
.ToList();
|
||||
FamilyBox.Text = "굴림";
|
||||
Reload();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void Reload()
|
||||
{
|
||||
GroupList.ItemsSource = designer.CollectFontUsage();
|
||||
}
|
||||
|
||||
private void OnApplySelected(object sender, RoutedEventArgs e)
|
||||
=> Apply(GroupList.SelectedItems.Cast<FontUsageGroup>().ToList());
|
||||
|
||||
private void OnApplyAll(object sender, RoutedEventArgs e)
|
||||
=> Apply(GroupList.Items.Cast<FontUsageGroup>().ToList());
|
||||
|
||||
private void Apply(List<FontUsageGroup> targets)
|
||||
{
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
MessageBox.Show("변경할 조합을 선택하세요.", "폰트 일괄 변경");
|
||||
return;
|
||||
}
|
||||
var family = FamilyBox.Text.Trim();
|
||||
if (family.Length == 0)
|
||||
{
|
||||
MessageBox.Show("글꼴명을 입력하세요.", "폰트 일괄 변경");
|
||||
return;
|
||||
}
|
||||
if (!double.TryParse(SizeBox.Text.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var sizePt)
|
||||
|| sizePt <= 0 || sizePt > 200)
|
||||
{
|
||||
MessageBox.Show("크기(pt)를 올바르게 입력하세요.", "폰트 일괄 변경");
|
||||
return;
|
||||
}
|
||||
|
||||
var newFont = new LegacyFont
|
||||
{
|
||||
Family = family,
|
||||
SizePt = sizePt,
|
||||
Bold = BoldBox.IsChecked == true,
|
||||
Italic = ItalicBox.IsChecked == true,
|
||||
Underline = UnderlineBox.IsChecked == true,
|
||||
};
|
||||
var changed = designer.ApplyFontBulk(targets, newFont);
|
||||
ResultText.Text = $"{changed}개 컨트롤 변경됨";
|
||||
Reload();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<UserControl x:Class="SheetMe.Designer.Views.InspectorView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ins="clr-namespace:SheetMe.Designer.ViewModels.Inspector"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance ins:InspectorViewModel}">
|
||||
|
||||
<UserControl.Resources>
|
||||
<!-- 행 VM 타입별 암시적 템플릿 -->
|
||||
<Style x:Key="RowLabel" TargetType="TextBlock">
|
||||
<Setter Property="Width" Value="86"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
</Style>
|
||||
<Style x:Key="RowEditor" TargetType="Control">
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
</Style>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:SectionRowViewModel}">
|
||||
<TextBlock Text="{Binding Label}" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"
|
||||
Margin="0,10,0,4" FontSize="12"/>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:TextRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:MultilineTextRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,0,0"/>
|
||||
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"
|
||||
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="48" MaxHeight="120"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:NumberRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ToggleRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<CheckBox IsChecked="{Binding IsOn}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ChoiceRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<ComboBox ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}" FontSize="12"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:TagPickerRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<Button DockPanel.Dock="Right" Content="…" Width="24" Margin="4,0,0,0"
|
||||
Command="{Binding BrowseCommand}" ToolTip="목록에서 선택"/>
|
||||
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"
|
||||
ToolTip="직접 입력 또는 … 버튼으로 선택"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<TextBlock Text="{Binding ValueText}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ToggleAdvancedRowViewModel}">
|
||||
<Button Content="{Binding ButtonText}" Command="{Binding ToggleCommand}"
|
||||
Margin="0,6,0,2" Padding="6,3" HorizontalAlignment="Stretch"
|
||||
Background="{DynamicResource B.Chip}" BorderBrush="{DynamicResource B.Line2}" FontSize="11" Foreground="{DynamicResource B.Muted}"/>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:AddPropertyRowViewModel}">
|
||||
<DockPanel Margin="0,6,0,2">
|
||||
<Button DockPanel.Dock="Right" Content="추가" Padding="8,2" Margin="4,0,0,0"
|
||||
Command="{Binding AddCommand}"
|
||||
ToolTip="레거시 컨트롤의 속성 이름을 정확히 입력하세요 (예: EnterTabYon, AutoHeight)"/>
|
||||
<TextBox Text="{Binding KeyText, UpdateSourceTrigger=PropertyChanged}" FontSize="12"
|
||||
ToolTip="새 속성 키 입력"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:QueryRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<Button DockPanel.Dock="Right" Content="편집…" Padding="6,1" Margin="4,0,0,0"
|
||||
Command="{Binding EditCommand}" ToolTip="쿼리 편집기 열기 (데이터소스 더블클릭과 동일)"/>
|
||||
<TextBlock Text="{Binding Summary}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
ToolTip="{Binding ValueText}"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ColorRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<!-- 스와치 = 피커 버튼(클릭 → 색상 선택 대화상자), 직접 입력도 병행 -->
|
||||
<Button DockPanel.Dock="Right" Width="26" Height="22" Margin="4,0,0,0" Padding="0"
|
||||
Command="{Binding PickCommand}" Cursor="Hand"
|
||||
ToolTip="클릭하여 색상 선택">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="{Binding Preview}" BorderBrush="{DynamicResource B.Line2}"
|
||||
BorderThickness="1" CornerRadius="4" SnapsToDevicePixels="True"/>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
</Button>
|
||||
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"
|
||||
ToolTip="R, G, B 또는 색 이름 (예: 224, 224, 224 / White) — 오른쪽 스와치 클릭 시 피커"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</UserControl.Resources>
|
||||
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="{Binding Summary}" FontWeight="Bold"
|
||||
Margin="10,10,10,2" Foreground="{DynamicResource B.Muted}" TextTrimming="CharacterEllipsis"/>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="10,0,10,10"/>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.</summary>
|
||||
public partial class InspectorView : UserControl
|
||||
{
|
||||
public InspectorView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.MainView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
||||
xmlns:v="clr-namespace:SheetMe.Designer.Views"
|
||||
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
|
||||
xmlns:bh="clr-namespace:SheetMe.Designer.Behaviors"
|
||||
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance vm:MainViewModel}"
|
||||
Title="{Binding Title}"
|
||||
Icon="pack://application:,,,/SheetMe.Designer;component/Assets/sheetme.ico"
|
||||
Width="1440" Height="920" MinWidth="940" MinHeight="640"
|
||||
WindowStyle="None" WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource B.AppBg}" FontFamily="Malgun Gothic" FontSize="13"
|
||||
Loaded="OnWindowLoaded" StateChanged="OnWindowStateChanged">
|
||||
|
||||
<!-- GlassFrameThickness 0,0,0,1: DWM 창 그림자 활성화([200]SheetMe 크롬과 동일) -->
|
||||
<shell:WindowChrome.WindowChrome>
|
||||
<shell:WindowChrome CaptionHeight="40" ResizeBorderThickness="6" GlassFrameThickness="0,0,0,1"
|
||||
CornerRadius="0" UseAeroCaptionButtons="False"/>
|
||||
</shell:WindowChrome.WindowChrome>
|
||||
|
||||
<Window.DataContext>
|
||||
<vm:MainViewModel/>
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Resources>
|
||||
<ctl:TypeToIconConverter x:Key="TypeToIcon"/>
|
||||
<ctl:TypeToCategoryConverter x:Key="TypeToCategory"/>
|
||||
<ctl:IconNameToVisualConverter x:Key="IconName"/>
|
||||
<ctl:InverseBoolConverter x:Key="InverseBool"/>
|
||||
|
||||
<!-- 팔레트(도구 상자/플라이아웃 공용) — 카테고리 그룹 뷰 -->
|
||||
<CollectionViewSource x:Key="PaletteGrouped" Source="{Binding PaletteItems}">
|
||||
<CollectionViewSource.GroupDescriptions>
|
||||
<PropertyGroupDescription PropertyName="Type" Converter="{StaticResource TypeToCategory}"/>
|
||||
</CollectionViewSource.GroupDescriptions>
|
||||
</CollectionViewSource>
|
||||
|
||||
<!-- 도구 상자 타일 카드([200] 컴포넌트 카드 룩) — 선택 하이라이트 없음 -->
|
||||
<Style x:Key="PaletteTile" TargetType="ListBoxItem">
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="bd" Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}"
|
||||
BorderThickness="1" CornerRadius="5" Margin="3" SnapsToDevicePixels="True">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 팔레트 카테고리 그룹 헤더 -->
|
||||
<Style x:Key="PaletteGroupHeader" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="10.5"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Setter Property="Margin" Value="5,9,0,4"/>
|
||||
</Style>
|
||||
|
||||
<!-- 플라이아웃/줌 팝업 행 버튼 — 왼정렬 hover 행 -->
|
||||
<Style x:Key="FlyoutRow" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Padding" Value="12,7"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" CornerRadius="6"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel>
|
||||
<!-- ===== 커스텀 타이틀바: 로고 + 메뉴 | 중앙 제목 | 창 제어 ===== -->
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource B.Titlebar}" Height="40"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 좌: 로고 + 메뉴 -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||
<Image Source="pack://application:,,,/SheetMe.Designer;component/Assets/sheetme-logo.png"
|
||||
Width="20" Height="20" VerticalAlignment="Center" RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
<Menu VerticalAlignment="Center" Margin="8,0,0,0">
|
||||
<MenuItem Header="파일(_F)">
|
||||
<MenuItem Header="새 서식(_N)" Command="{Binding NewFileCommand}" InputGestureText="Ctrl+N"/>
|
||||
<MenuItem Header="열기(_O)..." Command="{Binding OpenFileCommand}" InputGestureText="Ctrl+O"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="저장(_S)" Command="{Binding SaveFileCommand}" InputGestureText="Ctrl+S"/>
|
||||
<MenuItem Header="다른 이름으로 저장(_A)..." Command="{Binding SaveAsFileCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="DB에서 열기(_D)..." Command="{Binding OpenFromDbCommand}"/>
|
||||
<MenuItem Header="DB에 저장(_B)..." Command="{Binding SaveToDbCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="JSON 가져오기(_I)..." Command="{Binding ImportJsonCommand}"/>
|
||||
<MenuItem Header="JSON 내보내기(_E)..." Command="{Binding ExportJsonCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="미리보기(_V)..." Command="{Binding PreviewCommand}"/>
|
||||
<MenuItem Header="인쇄(_P)..." Command="{Binding PrintCommand}" InputGestureText="Ctrl+P"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="종료(_X)" Command="{Binding ExitCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="편집(_E)">
|
||||
<MenuItem Header="실행 취소(_U)" Command="{Binding CurrentDesigner.UndoCommand}" InputGestureText="Ctrl+Z"/>
|
||||
<MenuItem Header="다시 실행(_R)" Command="{Binding CurrentDesigner.RedoCommand}" InputGestureText="Ctrl+Y"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="잘라내기(_T)" Command="{Binding CurrentDesigner.CutCommand}" InputGestureText="Ctrl+X"/>
|
||||
<MenuItem Header="복사(_C)" Command="{Binding CurrentDesigner.CopyCommand}" InputGestureText="Ctrl+C"/>
|
||||
<MenuItem Header="붙여넣기(_P)" Command="{Binding CurrentDesigner.PasteCommand}" InputGestureText="Ctrl+V"/>
|
||||
<MenuItem Header="복제(_D)" Command="{Binding CurrentDesigner.DuplicateCommand}" InputGestureText="Ctrl+D"/>
|
||||
<MenuItem Header="삭제(_L)" Command="{Binding CurrentDesigner.DeleteCommand}" InputGestureText="Del"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="전체 선택(_A)" Command="{Binding CurrentDesigner.SelectAllCommand}" InputGestureText="Ctrl+A"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="배치(_A)">
|
||||
<MenuItem Header="맨 앞으로(_F)" Command="{Binding CurrentDesigner.BringToFrontCommand}"/>
|
||||
<MenuItem Header="맨 뒤로(_B)" Command="{Binding CurrentDesigner.SendToBackCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="그룹(_G)" Command="{Binding CurrentDesigner.GroupCommand}" InputGestureText="Ctrl+G"/>
|
||||
<MenuItem Header="그룹 해제(_U)" Command="{Binding CurrentDesigner.UngroupCommand}" InputGestureText="Ctrl+Shift+G"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="왼쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Left"/>
|
||||
<MenuItem Header="오른쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Right"/>
|
||||
<MenuItem Header="위 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Top"/>
|
||||
<MenuItem Header="아래 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Bottom"/>
|
||||
<MenuItem Header="가로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterH"/>
|
||||
<MenuItem Header="세로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterV"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="같은 크기로">
|
||||
<MenuItem Header="너비/높이 모두(_B)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Both"/>
|
||||
<MenuItem Header="너비(_W)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Width"/>
|
||||
<MenuItem Header="높이(_H)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Height"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="가로 간격">
|
||||
<MenuItem Header="균등(_E)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizEqual"/>
|
||||
<MenuItem Header="넓게(_I)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizIncrease"/>
|
||||
<MenuItem Header="좁게(_D)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizDecrease"/>
|
||||
<MenuItem Header="붙이기(_C)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizConcat"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="세로 간격">
|
||||
<MenuItem Header="균등(_E)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertEqual"/>
|
||||
<MenuItem Header="넓게(_I)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertIncrease"/>
|
||||
<MenuItem Header="좁게(_D)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertDecrease"/>
|
||||
<MenuItem Header="붙이기(_C)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertConcat"/>
|
||||
</MenuItem>
|
||||
<Separator/>
|
||||
<MenuItem Header="페이지 가로 가운데(_H)" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="H"/>
|
||||
<MenuItem Header="페이지 세로 가운데(_V)" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="V"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="{Binding CurrentDesigner.TabOrderMenuHeader, FallbackValue=탭순서 편집 시작}"
|
||||
Command="{Binding CurrentDesigner.TabOrderCommand}"
|
||||
IsChecked="{Binding CurrentDesigner.IsTabOrderMode, Mode=OneWay}"
|
||||
ToolTip="입력 컨트롤을 원하는 입력 순서대로 클릭한 뒤 완료 — Esc 취소"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="보기(_V)">
|
||||
<MenuItem Header="확대(_I)" Command="{Binding ZoomInCommand}" InputGestureText="Ctrl+휠↑"/>
|
||||
<MenuItem Header="축소(_O)" Command="{Binding ZoomOutCommand}" InputGestureText="Ctrl+휠↓"/>
|
||||
<MenuItem Header="100%(_R)" Command="{Binding ZoomResetCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem x:Name="ThemeMenuItem" Header="다크 테마(_D)" IsCheckable="True" Click="OnThemeToggleClick"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="도구(_T)">
|
||||
<MenuItem Header="서식 수정이력(_H)..." Command="{Binding SheetHistoryCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="상용구 관리(_W)..." Command="{Binding RecordWordCommand}"/>
|
||||
<MenuItem Header="폰트 일괄 변경(_F)..." Command="{Binding FontManagerCommand}"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 중앙: 창 제목 -->
|
||||
<TextBlock Grid.Column="1" Text="{Binding Title}" Foreground="{DynamicResource B.Muted}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="12"/>
|
||||
|
||||
<!-- 우: 창 제어 -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||
<Button Style="{StaticResource CaptionBtn}" Click="OnMinimizeClick" ToolTip="최소화">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
|
||||
</Button>
|
||||
<Button Style="{StaticResource CaptionBtn}" Click="OnMaxRestoreClick" ToolTip="최대화/복원">
|
||||
<TextBlock x:Name="MaxGlyph" Text="" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
|
||||
</Button>
|
||||
<Button Style="{StaticResource CloseBtn}" Click="OnCloseWinClick" ToolTip="닫기">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ===== 하단 상태바 (줌은 플로팅 바로 이동) ===== -->
|
||||
<Border DockPanel.Dock="Bottom" Background="{DynamicResource B.Titlebar}"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,1,0,0" Padding="12,6">
|
||||
<TextBlock Text="{Binding StatusText}" Foreground="{DynamicResource B.Muted}" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
|
||||
<!-- ===== 본문 3열: 좌 탭 패널 | 문서 탭+캔버스+플로팅 바 | 속성 ([200] 레이아웃) ===== -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="232" MinWidth="180" MaxWidth="480"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="280" MinWidth="220" MaxWidth="560"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 좌: 알약 탭 — 서식 목록 / 레이어 / 도구 상자 -->
|
||||
<Border Grid.Column="0" Background="{DynamicResource B.Panel}">
|
||||
<TabControl x:Name="LeftTabs" SelectedIndex="{Binding SelectedLeftTabIndex, Mode=TwoWay}">
|
||||
|
||||
<!-- 서식 목록 -->
|
||||
<TabItem>
|
||||
<TabItem.Header><TextBlock Text="서식 목록" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
|
||||
<DockPanel>
|
||||
<DockPanel DockPanel.Dock="Top" Margin="8,8,8,4">
|
||||
<Button DockPanel.Dock="Right" Content="검색" Margin="6,0,0,0"
|
||||
Command="{Binding SearchSheetsCommand}"/>
|
||||
<TextBox Text="{Binding SheetSearchKeyword, UpdateSourceTrigger=PropertyChanged}"
|
||||
ToolTip="서식명/코드 검색 (Enter)">
|
||||
<TextBox.InputBindings>
|
||||
<KeyBinding Key="Enter" Command="{Binding SearchSheetsCommand}"/>
|
||||
</TextBox.InputBindings>
|
||||
</TextBox>
|
||||
</DockPanel>
|
||||
<Grid>
|
||||
<ListBox x:Name="SheetListBox" ItemsSource="{Binding SheetList}"
|
||||
Margin="4,0,4,4"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
MouseDoubleClick="OnSheetListDoubleClick"
|
||||
ToolTip="더블클릭으로 열기 — 여러 서식을 탭으로 동시에 편집할 수 있습니다">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="1">
|
||||
<TextBlock Text="{Binding ShtCod}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
||||
Width="52" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasDesign}" Value="False">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Setter Property="Opacity" Value="0.6"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<TextBlock Text="불러오는 중..." HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
Visibility="{Binding IsSheetListLoading, Converter={StaticResource BoolToVisibility}}"/>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
<!-- 레이어 (+ 페이지) -->
|
||||
<TabItem>
|
||||
<TabItem.Header><TextBlock Text="레이어" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
|
||||
<DockPanel>
|
||||
<!-- 페이지 섹션 -->
|
||||
<DockPanel DockPanel.Dock="Top" Margin="0,4,0,0">
|
||||
<TextBlock DockPanel.Dock="Top" Text="페이지" FontWeight="Bold" FontSize="11"
|
||||
Margin="10,4,10,4" Foreground="{DynamicResource B.Muted}"/>
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="10,2,10,6">
|
||||
<Button Content="추가" Command="{Binding CurrentDesigner.AddPageCommand}"/>
|
||||
<Button Content="삭제" Margin="6,0,0,0" Command="{Binding CurrentDesigner.RemovePageCommand}"/>
|
||||
</StackPanel>
|
||||
<ListBox ItemsSource="{Binding CurrentDesigner.Pages}"
|
||||
SelectedItem="{Binding CurrentDesigner.SelectedPage}"
|
||||
MaxHeight="110" Margin="4,0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="2,1">
|
||||
<TextBlock Text="{Binding Index, StringFormat=페이지 {0}}" FontSize="12"/>
|
||||
<TextBlock Text="{Binding SizeText, StringFormat= ({0})}" FontSize="11" Foreground="{DynamicResource B.Muted}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
<Border DockPanel.Dock="Top" Height="1" Background="{DynamicResource B.Line}" Margin="8,2"/>
|
||||
<!-- 레이어 목록 (활성 페이지, 그리기 순서) -->
|
||||
<ListBox ItemsSource="{Binding CurrentDesigner.SelectedPage.Controls}"
|
||||
SelectedItem="{Binding CurrentDesigner.SelectedLayerItem}"
|
||||
Margin="4,2,4,6"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0,1">
|
||||
<CheckBox DockPanel.Dock="Right" IsChecked="{Binding IsLockedFlag}"
|
||||
ToolTip="잠금" Margin="4,0,0,0"/>
|
||||
<CheckBox DockPanel.Dock="Right" IsChecked="{Binding IsHiddenFlag}"
|
||||
ToolTip="숨김" Margin="4,0,0,0"/>
|
||||
<TextBlock Text="{Binding Id}" FontSize="12" TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
<!-- 도구 상자 ([200] 타일 카드 룩 — 드래그/더블클릭 배치) -->
|
||||
<TabItem>
|
||||
<TabItem.Header><TextBlock Text="도구 상자" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
|
||||
<ListBox ItemsSource="{Binding Source={StaticResource PaletteGrouped}}"
|
||||
ItemContainerStyle="{StaticResource PaletteTile}"
|
||||
Margin="6,4,6,6"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
ToolTip="더블클릭 또는 드래그로 캔버스에 배치">
|
||||
<b:Interaction.Behaviors>
|
||||
<bh:PaletteDragBehavior/>
|
||||
</b:Interaction.Behaviors>
|
||||
<!-- 그룹 세로 나열(GroupStyle.Panel 기본) + 그룹 내 타일 2열(ItemsPanel — 그룹 내부 항목이 상속) -->
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="2"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.GroupStyle>
|
||||
<GroupStyle>
|
||||
<GroupStyle.HeaderTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}" Style="{StaticResource PaletteGroupHeader}"/>
|
||||
</DataTemplate>
|
||||
</GroupStyle.HeaderTemplate>
|
||||
</GroupStyle>
|
||||
</ListBox.GroupStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel HorizontalAlignment="Center" Margin="2,9,2,8" Background="Transparent">
|
||||
<ContentControl Content="{Binding Type, Converter={StaticResource TypeToIcon}}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,5" IsTabStop="False" Focusable="False"/>
|
||||
<TextBlock Text="{Binding DisplayName}" FontSize="11" TextAlignment="Center"
|
||||
HorizontalAlignment="Center" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Column="1" Style="{StaticResource ColSplitter}" ToolTip="좌측 패널 너비 조절"/>
|
||||
|
||||
<!-- 중앙: 문서 탭(크롬식) + 캔버스 + 플로팅 팔레트 바 -->
|
||||
<Grid Grid.Column="2" Background="{DynamicResource B.CanvasBg}">
|
||||
<TabControl Style="{StaticResource DocTabs}"
|
||||
ItemsSource="{Binding OpenDesigners}"
|
||||
SelectedItem="{Binding CurrentDesigner}"
|
||||
ItemContainerStyle="{StaticResource DocTabItem}">
|
||||
<TabControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<ContentControl Content="{Binding Source=file-text, Converter={StaticResource IconName}, ConverterParameter=12}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center" IsTabStop="False" Focusable="False"/>
|
||||
<TextBlock Text="{Binding DisplayName}" MaxWidth="170" Style="{StaticResource TabHeaderText}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
<Button Content="✕" FontSize="9" Margin="7,0,0,0"
|
||||
Style="{StaticResource Subtle}" Padding="3,0" Cursor="Hand" ToolTip="문서 닫기"
|
||||
Command="{Binding DataContext.CloseDocumentCommand,
|
||||
RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</TabControl.ItemTemplate>
|
||||
<TabControl.ContentTemplate>
|
||||
<DataTemplate>
|
||||
<v:DesignerCanvasView/>
|
||||
</DataTemplate>
|
||||
</TabControl.ContentTemplate>
|
||||
</TabControl>
|
||||
|
||||
<!-- 플로팅 팔레트 바 (Figma 식 — 컨트롤 추가 플라이아웃 | 줌) -->
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
CornerRadius="12" Padding="7,5" HorizontalAlignment="Center" VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,20">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="20" ShadowDepth="3" Opacity="0.35" Color="Black"/>
|
||||
</Border.Effect>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ToggleButton Width="38" Height="34" Padding="0" Focusable="False"
|
||||
ToolTip="선택 도구 — 클릭으로 선택, 드래그로 이동"
|
||||
IsChecked="{Binding IsHandTool, Converter={StaticResource InverseBool}, Mode=OneWay}"
|
||||
Click="OnSelectToolClick"
|
||||
Content="{Binding Source=mouse-pointer-click, Converter={StaticResource IconName}, ConverterParameter=18}"/>
|
||||
<ToggleButton Width="38" Height="34" Padding="0" Focusable="False"
|
||||
ToolTip="손 도구 — 드래그로 화면 이동 (Space 누르는 동안 임시)"
|
||||
IsChecked="{Binding IsHandTool, Mode=OneWay}"
|
||||
Click="OnHandToolClick"
|
||||
Content="{Binding Source=hand, Converter={StaticResource IconName}, ConverterParameter=18}"/>
|
||||
<Border Width="1" Height="22" Background="{DynamicResource B.Line}" Margin="5,0" VerticalAlignment="Center"/>
|
||||
<Button x:Name="AddControlBtn" Style="{StaticResource Subtle}" Width="38" Height="34" Padding="0"
|
||||
ToolTip="컨트롤 추가 — 활성 페이지 중앙에 배치" Click="OnAddControlFlyoutClick"
|
||||
Content="{Binding Source=layout-grid, Converter={StaticResource IconName}, ConverterParameter=18}"/>
|
||||
<Border Width="1" Height="22" Background="{DynamicResource B.Line}" Margin="5,0" VerticalAlignment="Center"/>
|
||||
<Button Style="{StaticResource Subtle}" Width="30" Height="34" Padding="0" ToolTip="축소 (Ctrl+휠↓)"
|
||||
Command="{Binding ZoomOutCommand}"
|
||||
Content="{Binding Source=zoom-out, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
<Button x:Name="ZoomBtn" Style="{StaticResource Subtle}" Height="34" Padding="8,0,6,0" ToolTip="줌"
|
||||
Click="OnZoomFlyoutClick">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding CurrentDesigner.ZoomPercentText, FallbackValue=100%}"
|
||||
FontSize="12" MinWidth="38" TextAlignment="Center" VerticalAlignment="Center"/>
|
||||
<ContentControl Content="{Binding Source=chevron-down, Converter={StaticResource IconName}, ConverterParameter=11}"
|
||||
Margin="3,1,0,0" VerticalAlignment="Center" IsTabStop="False" Focusable="False"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource Subtle}" Width="30" Height="34" Padding="0" ToolTip="확대 (Ctrl+휠↑)"
|
||||
Command="{Binding ZoomInCommand}"
|
||||
Content="{Binding Source=zoom-in, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 컨트롤 추가 플라이아웃 (카테고리별 타일) -->
|
||||
<Popup x:Name="AddControlPopup" PlacementTarget="{Binding ElementName=AddControlBtn}" Placement="Top"
|
||||
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade" VerticalOffset="-8">
|
||||
<Border Margin="18" Background="{DynamicResource B.Panel}" BorderBrush="{DynamicResource B.Line}"
|
||||
BorderThickness="1" CornerRadius="10" MinWidth="300">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="16" ShadowDepth="3" Opacity="0.4" Color="Black"/>
|
||||
</Border.Effect>
|
||||
<ScrollViewer MaxHeight="440" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Source={StaticResource PaletteGrouped}}" Margin="10,9,10,10">
|
||||
<!-- 그룹 세로 나열 + 그룹 내 타일 4열([200] 플라이아웃 치수) -->
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="4"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.GroupStyle>
|
||||
<GroupStyle>
|
||||
<GroupStyle.HeaderTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}" FontSize="10.5" FontWeight="Bold"
|
||||
Foreground="{DynamicResource B.Muted}" Margin="2,9,0,6"/>
|
||||
</DataTemplate>
|
||||
</GroupStyle.HeaderTemplate>
|
||||
</GroupStyle>
|
||||
</ItemsControl.GroupStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Width="66" Margin="3" Padding="4,9,4,8" Click="OnFlyoutTileClick"
|
||||
Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}"
|
||||
ToolTip="{Binding DisplayName}">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<ContentControl Content="{Binding Type, Converter={StaticResource TypeToIcon}}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,5" IsTabStop="False" Focusable="False"/>
|
||||
<TextBlock Text="{Binding DisplayName}" FontSize="11" TextAlignment="Center"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
|
||||
<!-- 줌 플라이아웃 -->
|
||||
<Popup x:Name="ZoomPopup" PlacementTarget="{Binding ElementName=ZoomBtn}" Placement="Top"
|
||||
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade" VerticalOffset="-8">
|
||||
<Border Margin="18" Background="{DynamicResource B.Panel}" BorderBrush="{DynamicResource B.Line}"
|
||||
BorderThickness="1" CornerRadius="10" MinWidth="190">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="16" ShadowDepth="3" Opacity="0.4" Color="Black"/>
|
||||
</Border.Effect>
|
||||
<StackPanel Margin="5">
|
||||
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomInCommand}" Click="OnZoomRowClick">
|
||||
<DockPanel LastChildFill="True">
|
||||
<TextBlock DockPanel.Dock="Right" Text="Ctrl+휠↑" Foreground="{DynamicResource B.Muted}"
|
||||
FontSize="11.5" Margin="24,0,0,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="확대" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomOutCommand}" Click="OnZoomRowClick">
|
||||
<DockPanel LastChildFill="True">
|
||||
<TextBlock DockPanel.Dock="Right" Text="Ctrl+휠↓" Foreground="{DynamicResource B.Muted}"
|
||||
FontSize="11.5" Margin="24,0,0,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="축소" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</Button>
|
||||
<Border Height="1" Background="{DynamicResource B.Line}" Margin="8,5"/>
|
||||
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomResetCommand}" Click="OnZoomRowClick">
|
||||
<TextBlock Text="100%" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
|
||||
<GridSplitter Grid.Column="3" Style="{StaticResource ColSplitter}" ToolTip="속성 패널 너비 조절"/>
|
||||
|
||||
<!-- 우: 속성 인스펙터 -->
|
||||
<Border Grid.Column="4" Background="{DynamicResource B.Panel}">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource B.PanelHeader}" Padding="11,8"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
|
||||
<TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/>
|
||||
</Border>
|
||||
<v:InspectorView DataContext="{Binding CurrentDesigner.Inspector}"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Data.Stores;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 메인 셸 윈도우 — 커스텀 타이틀바(로고+메뉴) | 좌(서식 목록/도구상자/페이지/레이어) | 문서 탭 | 인스펙터.
|
||||
/// [200]SheetMe 크롬(WindowChrome CaptionHeight 40) 이식 — 창 제어/테마 토글은 뷰 전용 관심사라 코드비하인드 처리.
|
||||
/// </summary>
|
||||
public partial class MainView : Window
|
||||
{
|
||||
public MainView()
|
||||
{
|
||||
InitializeComponent();
|
||||
// StaysOpen=False 팝업은 바깥 클릭(버튼 포함)으로 먼저 닫힌다 — 토글 버튼 재클릭이 곧바로 다시 열지 않게 닫힌 시각 기록
|
||||
AddControlPopup.Closed += (_, _) => addControlPopupClosedAt = Environment.TickCount;
|
||||
ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount;
|
||||
}
|
||||
|
||||
/// <summary>기동 — 테마 메뉴 체크 동기화 + 뷰모델 Loaded 커맨드 실행(구 CustomWindow.LoadedCommand 대체)</summary>
|
||||
private void OnWindowLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
|
||||
if (DataContext is MainViewModel viewModel && viewModel.LoadedCommand?.CanExecute(null) == true)
|
||||
{
|
||||
viewModel.LoadedCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서식 목록 더블클릭 → 탭으로 열기(뷰모델 위임 — ListBox 더블클릭은 InputBinding 미지원)</summary>
|
||||
private void OnSheetListDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel viewModel)
|
||||
{
|
||||
viewModel.OpenSheetFromList(SheetListBox.SelectedItem as SheetSummary);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnThemeToggleClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ThemeManager.Toggle();
|
||||
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
|
||||
}
|
||||
|
||||
/// <summary>플로팅 바 — 컨트롤 추가 플라이아웃 토글(닫힘 직후 재클릭 가드)</summary>
|
||||
private void OnAddControlFlyoutClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Environment.TickCount - addControlPopupClosedAt > 150)
|
||||
{
|
||||
AddControlPopup.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>플로팅 바 — 줌 플라이아웃 토글</summary>
|
||||
private void OnZoomFlyoutClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Environment.TickCount - zoomPopupClosedAt > 150)
|
||||
{
|
||||
ZoomPopup.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>플라이아웃 타일 클릭 — 활성 페이지 중앙에 컨트롤 배치(팔레트 더블클릭과 동일 경로)</summary>
|
||||
private void OnFlyoutTileClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
AddControlPopup.IsOpen = false;
|
||||
if ((sender as FrameworkElement)?.DataContext is SheetMe.Core.Catalog.ControlDescriptor item
|
||||
&& DataContext is MainViewModel viewModel)
|
||||
{
|
||||
viewModel.CurrentDesigner?.AddPaletteItemAtCenter(item.Type);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>줌 플라이아웃 행 클릭 — 커맨드 실행 후 팝업 닫기</summary>
|
||||
private void OnZoomRowClick(object sender, RoutedEventArgs e) => ZoomPopup.IsOpen = false;
|
||||
|
||||
/// <summary>플로팅 바 — 선택 도구(기본)</summary>
|
||||
private void OnSelectToolClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel viewModel)
|
||||
{
|
||||
viewModel.IsHandTool = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>플로팅 바 — 손(팬) 도구</summary>
|
||||
private void OnHandToolClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel viewModel)
|
||||
{
|
||||
viewModel.IsHandTool = true;
|
||||
}
|
||||
}
|
||||
|
||||
private int addControlPopupClosedAt;
|
||||
private int zoomPopupClosedAt;
|
||||
|
||||
private void OnMinimizeClick(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
|
||||
|
||||
private void OnMaxRestoreClick(object sender, RoutedEventArgs e) =>
|
||||
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
|
||||
|
||||
private void OnCloseWinClick(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
/// <summary>최대화/복원 글리프 전환(Segoe MDL2: E922=최대화, E923=복원)</summary>
|
||||
private void OnWindowStateChanged(object sender, EventArgs e) =>
|
||||
MaxGlyph.Text = WindowState == WindowState.Maximized ? "" : "";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<UserControl x:Class="SheetMe.Designer.Views.PageView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance vm:PageViewModel}">
|
||||
|
||||
<!-- 종이 위 렌더는 레거시 충실 유지 — 앱 다크 테마의 암시 TextBlock 스타일(B.Ink 밝은 글자)이
|
||||
페이지 콘텐츠에 스미지 않게 여기서 기본(검정) 암시 스타일로 차단 -->
|
||||
<UserControl.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="Black"/>
|
||||
</Style>
|
||||
</UserControl.Resources>
|
||||
|
||||
<!-- 용지 1장: 그림자 + 종이 + 컨트롤 층. 텍스트 선명도는 Ideal 포맷팅 -->
|
||||
<Grid Width="{Binding WidthDip}" Height="{Binding HeightDip}"
|
||||
TextOptions.TextFormattingMode="Ideal">
|
||||
|
||||
<!-- 종이 그림자 (Effect 대신 오프셋 사각형 — 성능) -->
|
||||
<Border Margin="3,3,-3,-3" Background="#22000000"/>
|
||||
|
||||
<!-- 종이 -->
|
||||
<Border Background="{Binding PaperBrush}" BorderBrush="#D8DCE2" BorderThickness="1"/>
|
||||
|
||||
<!-- 컨트롤 층 (그리기 순서 = 컬렉션 순서, 마지막이 최상위) -->
|
||||
<ItemsControl ItemsSource="{Binding Controls}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
<Setter Property="Width" Value="{Binding Width}"/>
|
||||
<Setter Property="Height" Value="{Binding Height}"/>
|
||||
<Setter Property="Opacity" Value="{Binding DesignOpacity}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>용지 1장 렌더 뷰 — DataContext 는 PageViewModel.</summary>
|
||||
public partial class PageView : UserControl
|
||||
{
|
||||
public PageView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.PreviewWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="미리보기" Width="900" Height="1000"
|
||||
WindowStartupLocation="CenterOwner" Background="{DynamicResource B.CanvasBg}">
|
||||
<DockPanel>
|
||||
<ToolBarTray DockPanel.Dock="Top">
|
||||
<ToolBar>
|
||||
<Button Content="인쇄..." Padding="10,3" Click="OnPrint"/>
|
||||
<Separator/>
|
||||
<Button Content="-" Padding="8,3" Click="OnZoomOut"/>
|
||||
<TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/>
|
||||
<Button Content="+" Padding="8,3" Click="OnZoomIn"/>
|
||||
</ToolBar>
|
||||
</ToolBarTray>
|
||||
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center">
|
||||
<StackPanel.LayoutTransform>
|
||||
<ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/>
|
||||
</StackPanel.LayoutTransform>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>미리보기 창 — 편집 크롬 없는 페이지 렌더(인쇄와 동일 비주얼) + 줌/인쇄.</summary>
|
||||
public partial class PreviewWindow : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly DesignerViewModel designer;
|
||||
private double zoom = 1.0;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PreviewWindow(DesignerViewModel designer)
|
||||
{
|
||||
this.designer = designer;
|
||||
InitializeComponent();
|
||||
BuildPages();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void BuildPages()
|
||||
{
|
||||
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),
|
||||
Width = page.WidthDip,
|
||||
Height = page.HeightDip,
|
||||
};
|
||||
PagesHost.Children.Add(frame);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
MessageBox.Show($"인쇄 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.QueryEditorWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="쿼리 편집" Width="860" Height="560" MinWidth="640" MinHeight="400"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
|
||||
Text="작성 시점에 <<변수>> 가 실제 값으로 치환되어 실행됩니다. 우측 변수를 더블클릭하면 커서 위치에 삽입됩니다."/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
|
||||
<Button Content="확인" Padding="20,5" Click="OnConfirm"/>
|
||||
<Button Content="취소" Padding="20,5" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="230"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- SQL 편집 영역 -->
|
||||
<TextBox x:Name="SqlBox" Grid.Column="0"
|
||||
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
|
||||
AcceptsReturn="True" AcceptsTab="True"
|
||||
TextWrapping="NoWrap"
|
||||
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
|
||||
TextChanged="OnSqlChanged"/>
|
||||
|
||||
<GridSplitter Grid.Column="1" HorizontalAlignment="Stretch" Background="{DynamicResource B.Line}"/>
|
||||
|
||||
<!-- 치환 변수 목록 -->
|
||||
<DockPanel Grid.Column="2">
|
||||
<TextBlock DockPanel.Dock="Top" Text="치환 변수 (더블클릭 삽입)" FontWeight="Bold"
|
||||
Foreground="{DynamicResource B.Muted}" Margin="4,0,0,6"/>
|
||||
<ListBox x:Name="VariableList" FontSize="12" MouseDoubleClick="OnInsertVariable"
|
||||
FontFamily="Consolas, D2Coding, 굴림체"/>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 큰 SQL 편집 영역 + 치환 변수 삽입.
|
||||
/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery — <<PatientInfo/SheetInfo/WorkInfo.속성>> 리플렉션 치환.
|
||||
/// 변수 목록 출처: [021]SheetLoadOperatingInfo 의 bzPatientInfo/bzSheetInfo/bzWorkInfo 공개 속성(스칼라만 큐레이션, 2026-07-16).
|
||||
/// </summary>
|
||||
public partial class QueryEditorWindow : Window
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>치환 변수 목록 — 작성 시점 실제 값으로 치환됨</summary>
|
||||
private static readonly string[] Variables =
|
||||
{
|
||||
"<<PatientInfo.ChtNum>>",
|
||||
"<<PatientInfo.ComNum>>",
|
||||
"<<PatientInfo.PatTyp>>",
|
||||
"<<PatientInfo.OdrNum>>",
|
||||
"<<PatientInfo.OdrSeq>>",
|
||||
"<<SheetInfo.ShtCod>>",
|
||||
"<<SheetInfo.ShtNam>>",
|
||||
"<<SheetInfo.EmrKey>>",
|
||||
"<<SheetInfo.EmrGbn>>",
|
||||
"<<SheetInfo.AdpDtm>>",
|
||||
"<<SheetInfo.PatTyp>>",
|
||||
"<<SheetInfo.OdrNum>>",
|
||||
"<<SheetInfo.OdrSeq>>",
|
||||
"<<SheetInfo.TemKey>>",
|
||||
"<<WorkInfo.WrkUid>>",
|
||||
"<<WorkInfo.WrkNam>>",
|
||||
"<<WorkInfo.WrkDte>>",
|
||||
"<<WorkInfo.WrkDtm>>",
|
||||
"<<WorkInfo.AdpDep>>",
|
||||
"<<WorkInfo.AdpDtm>>",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>편집 결과 SQL — 확인 시 채워짐</summary>
|
||||
public string QueryText { get; private set; } = string.Empty;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public QueryEditorWindow(string ownerLabel, string initialQuery)
|
||||
{
|
||||
InitializeComponent();
|
||||
Title = $"쿼리 편집 — {ownerLabel}";
|
||||
SqlBox.Text = initialQuery;
|
||||
VariableList.ItemsSource = Variables;
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
SqlBox.Focus();
|
||||
SqlBox.CaretIndex = SqlBox.Text.Length;
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnSqlChanged(object sender, TextChangedEventArgs e)
|
||||
=> LengthText.Text = $"{SqlBox.Text.Length:N0}자";
|
||||
|
||||
private void OnInsertVariable(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (VariableList.SelectedItem is not string variable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var caret = SqlBox.CaretIndex;
|
||||
SqlBox.Text = SqlBox.Text.Insert(caret, variable);
|
||||
SqlBox.CaretIndex = caret + variable.Length;
|
||||
SqlBox.Focus();
|
||||
}
|
||||
|
||||
private void OnConfirm(object sender, RoutedEventArgs e)
|
||||
{
|
||||
QueryText = SqlBox.Text;
|
||||
DialogResult = true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.RecordWordDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="상용구 관리" Width="560" Height="520" MinWidth="480" MinHeight="380"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
|
||||
<!-- 입력/버튼 -->
|
||||
<DockPanel DockPanel.Dock="Bottom" Margin="0,8,0,0">
|
||||
<Button DockPanel.Dock="Right" Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
<Button DockPanel.Dock="Right" Content="추가" Padding="16,4" Click="OnAdd" IsDefault="True"/>
|
||||
<TextBox x:Name="NewWordBox" Padding="4,3" Margin="0,0,8,0"
|
||||
VerticalContentAlignment="Center"
|
||||
ToolTip="새 상용구 입력 후 Enter 또는 추가"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 목록 + 우측 조작 -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<ListBox x:Name="WordList" Grid.Column="0" FontSize="13"
|
||||
MouseDoubleClick="OnEdit"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Value}" TextWrapping="Wrap" Margin="2"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="8,0,0,0">
|
||||
<Button Content="수정" Padding="10,3" Click="OnEdit"/>
|
||||
<Button Content="삭제" Padding="10,3" Margin="0,6,0,0" Click="OnRemove"/>
|
||||
<Separator Margin="0,10"/>
|
||||
<Button Content="위로 ▲" Padding="10,3" Click="OnMoveUp"/>
|
||||
<Button Content="아래로 ▼" Padding="10,3" Margin="0,6,0,0" Click="OnMoveDown"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 서식별 상용구 관리 대화상자 — E_SHTWRDMST 1단계 문구 추가/수정/삭제/순서.
|
||||
/// 레거시 런타임 상용구 팝업(MRecordWord)이 그대로 읽어가는 데이터를 관리한다.
|
||||
/// </summary>
|
||||
public partial class RecordWordDialogView : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly RecordWordStore store;
|
||||
private readonly string shtCod;
|
||||
private readonly ObservableCollection<RecordWordInfo> words = new();
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public RecordWordDialogView(RecordWordStore store, string shtCod, string sheetTitle)
|
||||
{
|
||||
this.store = store;
|
||||
this.shtCod = shtCod;
|
||||
InitializeComponent();
|
||||
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 작성 화면의 상용구 팝업에 표시되는 문구입니다. " +
|
||||
"(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)";
|
||||
WordList.ItemsSource = words;
|
||||
Loaded += (_, _) => Reload();
|
||||
NewWordBox.Loaded += (_, _) => NewWordBox.Focus();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void Reload()
|
||||
{
|
||||
try
|
||||
{
|
||||
words.Clear();
|
||||
foreach (var word in store.List(shtCod))
|
||||
{
|
||||
words.Add(word);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"상용구 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAdd(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var value = NewWordBox.Text.Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
store.Add(shtCod, value, Environment.UserName);
|
||||
NewWordBox.Clear();
|
||||
NewWordBox.Focus();
|
||||
Reload();
|
||||
WordList.SelectedIndex = words.Count - 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"추가 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEdit(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (WordList.SelectedItem is not RecordWordInfo selected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var editor = new QueryEditorWindow($"상용구 수정", selected.Value) { Owner = this };
|
||||
// QueryEditorWindow 재사용(큰 편집 영역) — 변수 삽입은 무시해도 무해
|
||||
if (editor.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var newValue = editor.QueryText.Trim();
|
||||
if (newValue.Length == 0 || newValue == selected.Value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
store.Update(selected.Key, newValue, Environment.UserName);
|
||||
Reload();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"수정 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRemove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (WordList.SelectedItem is not RecordWordInfo selected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (MessageBox.Show($"삭제할까요?\n\n{Truncate(selected.Value)}", "상용구 삭제",
|
||||
MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
store.Remove(selected.Key);
|
||||
Reload();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"삭제 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMoveUp(object sender, RoutedEventArgs e) => Move(-1);
|
||||
|
||||
private void OnMoveDown(object sender, RoutedEventArgs e) => Move(+1);
|
||||
|
||||
private void Move(int delta)
|
||||
{
|
||||
var index = WordList.SelectedIndex;
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= words.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
words.Move(index, target);
|
||||
store.Reorder(words.Select(w => w.Key).ToList(), Environment.UserName);
|
||||
WordList.SelectedIndex = target;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"순서 변경 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
Reload();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value)
|
||||
=> value.Length > 80 ? value[..80] + "…" : value;
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.RegisterSheetDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="신규 서식 등록" Width="420" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" ResizeMode="NoResize">
|
||||
<StackPanel Margin="16">
|
||||
<TextBlock Text="E_ShtMst 에 등록되지 않은 서식입니다. 신규 등록 후 저장합니다."
|
||||
TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,12"/>
|
||||
|
||||
<DockPanel Margin="0,3">
|
||||
<TextBlock Text="서식 코드" Width="80" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="CodeBox" MaxLength="10" Padding="4,3"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,3">
|
||||
<TextBlock Text="서식 명칭" Width="80" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="NameBox" MaxLength="100" Padding="4,3"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,3">
|
||||
<TextBlock Text="분류 코드" Width="80" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="ClassBox" MaxLength="5" Padding="4,3"
|
||||
ToolTip="선택 입력 — 예: A(초진), J(진단서류) 등 병원 분류 체계"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="기본값: 디자인 서식(ShtTyp='D'), 사용 'Y', 서식생성기 사용 'Y' — 상세 속성은 기록지정보 마스터에서 설정"
|
||||
TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" FontSize="11" Margin="0,10,0,0"/>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
|
||||
<Button Content="등록 후 저장" Padding="14,5" Click="OnConfirm" IsDefault="True"/>
|
||||
<Button Content="취소" Padding="14,5" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>신규 서식(E_ShtMst) 최소 등록 대화상자 — 코드/명칭/분류 입력.</summary>
|
||||
public partial class RegisterSheetDialogView : Window
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>서식 코드</summary>
|
||||
public string SheetCode => CodeBox.Text.Trim();
|
||||
|
||||
/// <summary>서식 명칭</summary>
|
||||
public string SheetName => NameBox.Text.Trim();
|
||||
|
||||
/// <summary>분류 코드(선택)</summary>
|
||||
public string? ClassCode => ClassBox.Text.Trim().Length == 0 ? null : ClassBox.Text.Trim();
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public RegisterSheetDialogView(string initialCode, string initialName)
|
||||
{
|
||||
InitializeComponent();
|
||||
CodeBox.Text = initialCode;
|
||||
NameBox.Text = initialName;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnConfirm(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SheetCode.Length == 0 || SheetName.Length == 0)
|
||||
{
|
||||
MessageBox.Show("서식 코드와 명칭을 입력하세요.", "신규 서식 등록");
|
||||
return;
|
||||
}
|
||||
DialogResult = true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<UserControl x:Class="SheetMe.Designer.Views.SelectionOverlayView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance vm:SelectionOverlayViewModel}"
|
||||
IsHitTestVisible="False">
|
||||
|
||||
<!-- 선택/마퀴/가이드 표시 전용 오버레이 — 입력은 월드 파이프라인이 처리(기하 히트테스트) -->
|
||||
<Canvas>
|
||||
|
||||
<!-- 정렬 가이드선 -->
|
||||
<ItemsControl ItemsSource="{Binding Guides}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Line Stroke="#FF4FA3" StrokeThickness="1" StrokeDashArray="4 3">
|
||||
<Line.Style>
|
||||
<Style TargetType="Line">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsVertical}" Value="True">
|
||||
<Setter Property="X1" Value="{Binding Position}"/>
|
||||
<Setter Property="X2" Value="{Binding Position}"/>
|
||||
<Setter Property="Y1" Value="-100000"/>
|
||||
<Setter Property="Y2" Value="100000"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsVertical}" Value="False">
|
||||
<Setter Property="Y1" Value="{Binding Position}"/>
|
||||
<Setter Property="Y2" Value="{Binding Position}"/>
|
||||
<Setter Property="X1" Value="-100000"/>
|
||||
<Setter Property="X2" Value="100000"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Line.Style>
|
||||
</Line>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 선택 박스 -->
|
||||
<Rectangle Canvas.Left="{Binding SelX}" Canvas.Top="{Binding SelY}"
|
||||
Width="{Binding SelW}" Height="{Binding SelH}"
|
||||
Stroke="#1E7BE8" StrokeThickness="1"
|
||||
Visibility="{Binding HasSelection, Converter={StaticResource BoolToVisibility}}"/>
|
||||
|
||||
<!-- 리사이즈 핸들 8개 -->
|
||||
<ItemsControl ItemsSource="{Binding Handles}"
|
||||
Visibility="{Binding ShowHandles, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Rectangle Width="8" Height="8" Fill="White" Stroke="#1E7BE8" StrokeThickness="1"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 마퀴(러버밴드) -->
|
||||
<Rectangle Canvas.Left="{Binding MarX}" Canvas.Top="{Binding MarY}"
|
||||
Width="{Binding MarW}" Height="{Binding MarH}"
|
||||
Stroke="#1E7BE8" StrokeThickness="1" StrokeDashArray="3 2" Fill="#181E7BE8"
|
||||
Visibility="{Binding HasMarquee, Converter={StaticResource BoolToVisibility}}"/>
|
||||
|
||||
<!-- 탭순서 배지 (편집 모드 전용) -->
|
||||
<ItemsControl ItemsSource="{Binding TabBadges}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border MinWidth="18" Height="18" CornerRadius="9" Padding="4,0"
|
||||
BorderThickness="1.5" BorderBrush="White">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="#9AA6B4"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsAssigned}" Value="True">
|
||||
<Setter Property="Background" Value="#1E7BE8"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<TextBlock Text="{Binding Text}" Foreground="White" FontSize="10" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Canvas>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>선택/마퀴/가이드 오버레이 — 표시 전용(입력 처리 없음).</summary>
|
||||
public partial class SelectionOverlayView : UserControl
|
||||
{
|
||||
public SelectionOverlayView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.SheetHistoryDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="서식 수정이력" Width="560" Height="520" MinWidth="480" MinHeight="380"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
|
||||
<Button Content="열람(새 탭)" Padding="16,4" Click="OnOpen" IsDefault="True"
|
||||
ToolTip="선택한 버전을 새 탭으로 엽니다 — 열람 후 'DB에 저장'하면 그 내용이 새 활성 버전이 됩니다(복원)"/>
|
||||
<Button Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<ListView x:Name="VersionList" MouseDoubleClick="OnOpen"
|
||||
VirtualizingPanel.IsVirtualizing="True">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="버전(SdgKey)" Width="110" DisplayMemberBinding="{Binding SdgKey}"/>
|
||||
<GridViewColumn Header="수정일시" Width="150" DisplayMemberBinding="{Binding UpdDtmText}"/>
|
||||
<GridViewColumn Header="수정자" Width="110" DisplayMemberBinding="{Binding UpdUid}"/>
|
||||
<GridViewColumn Header="상태" Width="80">
|
||||
<GridViewColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding StatusText}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding StatusText}" Value="활성">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Success}"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</DataTemplate>
|
||||
</GridViewColumn.CellTemplate>
|
||||
</GridViewColumn>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>이력 목록 표시용 행</summary>
|
||||
public sealed class VersionRow
|
||||
{
|
||||
/// <summary>디자인 버전 키</summary>
|
||||
public decimal SdgKey { get; init; }
|
||||
|
||||
/// <summary>수정일시 표시(yyyy-MM-dd HH:mm)</summary>
|
||||
public string UpdDtmText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>수정자</summary>
|
||||
public string UpdUid { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>상태 표시(활성/이력)</summary>
|
||||
public string StatusText { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서식 수정이력 대화상자 — E_SdgMst 버전 목록(레거시 UcSheetHistory 이식).
|
||||
/// 선택 버전을 새 탭으로 열람하고, 열람본을 'DB에 저장'하면 새 활성 버전이 되어 복원 흐름이 완성된다.
|
||||
/// </summary>
|
||||
public partial class SheetHistoryDialogView : Window
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>선택된 버전 키 — 열람 확정 시 채워짐</summary>
|
||||
public decimal? SelectedSdgKey { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public SheetHistoryDialogView(string shtCod, string sheetTitle, List<DesignVersionInfo> versions)
|
||||
{
|
||||
InitializeComponent();
|
||||
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 저장할 때마다 이전 버전이 이력으로 보존됩니다. " +
|
||||
"이력 버전을 열람한 뒤 'DB에 저장'하면 해당 내용이 새 활성 버전이 됩니다(복원).";
|
||||
VersionList.ItemsSource = versions.Select(v => new VersionRow
|
||||
{
|
||||
SdgKey = v.SdgKey,
|
||||
UpdDtmText = FormatDtm(v.UpdDtm),
|
||||
UpdUid = v.UpdUid,
|
||||
StatusText = v.Deleted ? "이력" : "활성",
|
||||
}).ToList();
|
||||
CountText.Text = $"{versions.Count}개 버전";
|
||||
if (VersionList.Items.Count > 0)
|
||||
{
|
||||
VersionList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnOpen(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (VersionList.SelectedItem is not VersionRow row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SelectedSdgKey = row.SdgKey;
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
/// <summary>yyyyMMddHHmm(12) / yyyyMMddHHmmss(14) → 사람이 읽는 형식</summary>
|
||||
private static string FormatDtm(string dtm)
|
||||
{
|
||||
if (dtm.Length >= 12
|
||||
&& int.TryParse(dtm[..4], out _))
|
||||
{
|
||||
var time = $"{dtm[8..10]}:{dtm[10..12]}";
|
||||
return $"{dtm[..4]}-{dtm[4..6]}-{dtm[6..8]} {time}";
|
||||
}
|
||||
return dtm;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.SheetOpenDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="DB에서 서식 열기" Width="560" Height="520"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<DockPanel DockPanel.Dock="Top">
|
||||
<Button DockPanel.Dock="Right" Content="검색" Padding="14,4" Margin="6,0,0,0" Click="OnSearch" IsDefault="True"/>
|
||||
<TextBox x:Name="SearchBox" Padding="4,3" VerticalContentAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
|
||||
<Button Content="열기" Padding="18,5" Click="OnOpen"/>
|
||||
<Button Content="취소" Padding="18,5" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<ListView x:Name="SheetList" Margin="0,10,0,0" MouseDoubleClick="OnOpen">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="서식코드" Width="110" DisplayMemberBinding="{Binding ShtCod}"/>
|
||||
<GridViewColumn Header="서식명" Width="310" DisplayMemberBinding="{Binding Name}"/>
|
||||
<GridViewColumn Header="디자인" Width="70">
|
||||
<GridViewColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="있음" Foreground="{DynamicResource B.Success}"
|
||||
Visibility="{Binding HasDesign, Converter={StaticResource BoolToVisibility}}"/>
|
||||
</DataTemplate>
|
||||
</GridViewColumn.CellTemplate>
|
||||
</GridViewColumn>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// DB 서식 열기 대화상자 — 검색/목록/선택.
|
||||
/// 검색 실행 콜백을 주입받는 얇은 대화상자(모달 결과 = SelectedSheet).
|
||||
/// </summary>
|
||||
public partial class SheetOpenDialogView : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly Func<string?, List<SheetSummary>> search;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택된 서식 — 확인 시 채워짐</summary>
|
||||
public SheetSummary? SelectedSheet { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public SheetOpenDialogView(Func<string?, List<SheetSummary>> search)
|
||||
{
|
||||
this.search = search;
|
||||
InitializeComponent();
|
||||
Loaded += (_, _) => RunSearch();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnSearch(object sender, RoutedEventArgs e) => RunSearch();
|
||||
|
||||
private void RunSearch()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = search(SearchBox.Text);
|
||||
SheetList.ItemsSource = result;
|
||||
CountText.Text = $"{result.Count}건";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"서식 목록 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpen(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SheetList.SelectedItem is not SheetSummary selected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!selected.HasDesign)
|
||||
{
|
||||
MessageBox.Show("선택한 서식에는 저장된 디자인이 없습니다.", "열기",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
SelectedSheet = selected;
|
||||
DialogResult = true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.TagPickerDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="태그 선택" Width="480" Height="560"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock x:Name="TitleText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
|
||||
<TextBox x:Name="SearchBox" DockPanel.Dock="Top" Padding="4,3"
|
||||
TextChanged="OnSearchChanged" VerticalContentAlignment="Center"/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
|
||||
<Button Content="값 지우기" Padding="12,4" Click="OnClear"/>
|
||||
<Button Content="선택" Padding="18,4" Margin="8,0,0,0" Click="OnPick" IsDefault="True"/>
|
||||
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<ListBox x:Name="TagList" Margin="0,8,0,0" MouseDoubleClick="OnPick"
|
||||
VirtualizingPanel.IsVirtualizing="True" FontSize="13"/>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 태그 선택 대화상자 — 검색(부분 일치, 공백 구분 다중 토큰 AND) + 목록 선택.
|
||||
/// '값 지우기'는 빈 값으로 확정(속성 제거).
|
||||
/// </summary>
|
||||
public partial class TagPickerDialogView : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly IReadOnlyList<string> allTags;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택된 태그 — '값 지우기'면 빈 문자열</summary>
|
||||
public string? SelectedTag { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue)
|
||||
{
|
||||
allTags = tags;
|
||||
InitializeComponent();
|
||||
TitleText.Text = title;
|
||||
ApplyFilter(string.Empty);
|
||||
if (!string.IsNullOrEmpty(currentValue))
|
||||
{
|
||||
TagList.SelectedItem = tags.FirstOrDefault(t => t == currentValue);
|
||||
TagList.ScrollIntoView(TagList.SelectedItem);
|
||||
}
|
||||
Loaded += (_, _) => SearchBox.Focus();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnSearchChanged(object sender, TextChangedEventArgs e)
|
||||
=> ApplyFilter(SearchBox.Text);
|
||||
|
||||
private void ApplyFilter(string keyword)
|
||||
{
|
||||
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var filtered = tokens.Length == 0
|
||||
? allTags
|
||||
: allTags.Where(t => tokens.All(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
TagList.ItemsSource = filtered;
|
||||
CountText.Text = $"{filtered.Count}/{allTags.Count}건";
|
||||
if (filtered.Count > 0)
|
||||
{
|
||||
TagList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (TagList.SelectedItem is not string tag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SelectedTag = tag;
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void OnClear(object sender, RoutedEventArgs e)
|
||||
{
|
||||
SelectedTag = string.Empty;
|
||||
DialogResult = true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"His": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=__HOST__)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=__SERVICE__)));User Id=__USER__;Password=__PASSWORD__;"
|
||||
},
|
||||
"FormStore": {
|
||||
"SaveMode": "File",
|
||||
"XmlFolder": "forms"
|
||||
},
|
||||
"Designer": {
|
||||
"GridSize": 4,
|
||||
"SnapThreshold": 6
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user