초기 커밋: 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
@@ -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
}