using System.Windows; using SheetMe.Designer.ViewModels; using SheetMe.Designer.ViewModels.Controls; namespace SheetMe.Designer.Services; /// 포인터 컨텍스트 — 수정키/클릭 수 public readonly record struct PointerContext(bool Ctrl, bool Shift, bool Alt, int ClickCount); /// 커서 종류 — 뷰(Behavior)가 WPF Cursor 로 매핑 public enum CursorKind { Arrow, SizeAll, SizeNWSE, SizeNESW, SizeNS, SizeWE, } /// /// 마우스 상호작용 상태머신 — 월드 좌표만 다루는 순수 로직(뷰 비의존). /// 상태: None → PendingMove(임계 대기) → Move / Resize / Marquee. /// 드래그 = Undo 1스텝(첫 실이동 시 스냅샷), 절대좌표 재계산(드리프트 방지). /// 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 origBounds = new(); private bool undoCaptured; #endregion #region Constructors public InteractionController(DesignerViewModel designer) { this.designer = designer; } #endregion #region Methods - Pointer /// 마우스 다운(월드 좌표) 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)); } /// 마우스 이동(월드 좌표) 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; } } /// 마우스 업(월드 좌표) 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(); } /// /// 진행 중 드래그만 취소(원위치 복원) — 캡처 상실 등 비정상 종료용. /// 유휴 상태에서는 아무것도 하지 않는다(정상 마우스 업 후 캡처 해제가 선택을 지우면 안 됨). /// 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; } } /// Esc — 탭순서 모드 취소 / 진행 중 드래그 취소 / 유휴 상태면 선택 해제 public void Cancel() { if (designer.IsTabOrderMode) { designer.CancelTabOrderMode(); return; } if (mode == Mode.None) { designer.Selection.Clear(); return; } CancelDrag(); } /// 커서 판정(호버 피드백) 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(); // Primary 는 없을 수 있다(선택 항목은 있는데 주선택이 비는 경로가 있다) — // ?! 로 넘기면 PageOf → RootAncestorOf 가 즉시 역참조해 이동 시작이 예외로 죽는다 var anchor = designer.Selection.Primary ?? designer.Selection.Items.FirstOrDefault(); var page = (anchor is null ? null : designer.PageOf(anchor)) ?? 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(); } /// 드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝 private void EnsureUndoSnapshot() { if (!undoCaptured) { designer.Undo.Snapshot(); undoCaptured = true; } } /// 선택 항목의 월드 bbox·개별 원본 bounds 캡처(X 는 페이지 로컬 = 월드 X, Y 는 월드) 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 }