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