초기 커밋: SheetMe 서식생성기 (P0~P5 완료 상태)
레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너. 기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49. 이 커밋에 함께 포함된 자격증명 분리: - appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환 - 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외, csproj Debug 조건부 복사라 Release 산출물에 실리지 않음) - ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경, 미치환 플레이스홀더는 '미설정'으로 간주 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
Reference in New Issue
Block a user