초기 커밋: 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:
Msystech
2026-08-11 17:20:22 +09:00
co-authored by Claude Fable 5
commit 16c07f48dc
102 changed files with 14210 additions and 0 deletions
+16
View File
@@ -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>
+172
View File
@@ -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 &lt;입력.xml&gt; &lt;출력.png&gt; — 첫 페이지를 오프스크린 렌더해 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
}
+393
View File
@@ -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 &lt;리포트&gt; [N] : 읽기 전용 — 활성 디자인 N건 일괄 Read→Write 의미론 diff 통계.
/// --db-save-smoke &lt;서식코드&gt; &lt;리포트&gt; : 쓰기 — 무변경 재저장 후 재로드 비교 + 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 바인딩 로더.
/// 우선순위: 환경변수 &gt; appsettings.Development.json &gt; 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
}
+125
View File
@@ -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();
}
}
+540
View File
@@ -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="&#xE921;" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
</Button>
<Button Style="{StaticResource CaptionBtn}" Click="OnMaxRestoreClick" ToolTip="최대화/복원">
<TextBlock x:Name="MaxGlyph" Text="&#xE922;" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
</Button>
<Button Style="{StaticResource CloseBtn}" Click="OnCloseWinClick" ToolTip="닫기">
<TextBlock Text="&#xE8BB;" 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>
+110
View File
@@ -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 ? "" : "";
}
+46
View File
@@ -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="작성 시점에 &lt;&lt;변수&gt;&gt; 가 실제 값으로 치환되어 실행됩니다. 우측 변수를 더블클릭하면 커서 위치에 삽입됩니다."/>
<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 — &lt;&lt;PatientInfo/SheetInfo/WorkInfo.속성&gt;&gt; 리플렉션 치환.
/// 변수 목록 출처: [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
}
+13
View File
@@ -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
}
}