using System.Collections.ObjectModel;
using System.Windows;
using SheetMe.Core.Layout;
namespace SheetMe.Designer.ViewModels;
///
/// 오버레이 가이드선 1개 — 월드 좌표 선분(양끝).
///
/// 예전에는 위치 하나만 들고 화면 끝에서 끝까지(±100000) 그었다. 그러면 "무엇에 맞춘 것인지"가
/// 안 보인다 — 종이 밖 회색 배경까지 선이 뻗어 대비도 죽었다. 관련된 것들만 잇는다.
///
public sealed class GuideLineInfo
{
public double X1 { get; init; }
public double Y1 { get; init; }
public double X2 { get; init; }
public double Y2 { get; init; }
/// 수직선 여부 — 진단·테스트용 파생(뷰는 X1..Y2 만 쓴다)
public bool IsVertical => Math.Abs(X1 - X2) < 0.001;
/// 정합 좌표 — 진단·테스트용 파생
public double Position => IsVertical ? X1 : Y1;
}
///
/// 간격 균등 표시 1개 — 막대 하나와 양끝 갈고리 둘. 전부 월드 좌표.
/// 좌표를 여기서 다 구워 두는 것은 이 저장소 규약이다( 와 같다) —
/// XAML 에서는 산술을 할 수 없다.
///
public sealed class SpacingMarkInfo
{
public double X1 { get; init; }
public double Y1 { get; init; }
public double X2 { get; init; }
public double Y2 { get; init; }
/// 시작 쪽 갈고리
public double CapAX1 { get; init; }
public double CapAY1 { get; init; }
public double CapAX2 { get; init; }
public double CapAY2 { get; init; }
/// 끝 쪽 갈고리
public double CapBX1 { get; init; }
public double CapBY1 { get; init; }
public double CapBX2 { get; init; }
public double CapBY2 { get; init; }
/// 간격값 표시
public string Text { get; init; } = string.Empty;
public double TextX { get; init; }
public double TextY { get; init; }
}
/// 잠긴 컨트롤 하나의 자리 — 월드 좌표. 오버레이가 점선 테두리로 그린다.
public sealed class LockMarkInfo
{
/// 좌상단 X(월드)
public double X { get; init; }
/// 좌상단 Y(월드)
public double Y { get; init; }
/// 너비(월드)
public double W { get; init; }
/// 높이(월드)
public double H { get; init; }
}
/// 탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)
public sealed class TabBadgeInfo
{
/// 배지 X(월드)
public double X { get; init; }
/// 배지 Y(월드)
public double Y { get; init; }
/// 표시 텍스트(순번 또는 "–")
public string Text { get; init; } = string.Empty;
/// 순번 지정 여부(지정=파랑, 미지정=회색)
public bool IsAssigned { get; init; }
}
/// 선택 핸들 1개 — 월드 좌표(중심점)
public sealed class HandleInfo
{
/// 핸들 인덱스(0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)
public int Index { get; init; }
/// 핸들 좌상단 X(월드)
public double X { get; init; }
/// 핸들 좌상단 Y(월드)
public double Y { get; init; }
}
///
/// 선택/마퀴/가이드 오버레이 표시 상태 — 전부 월드 좌표.
/// 오버레이 뷰는 이 상태를 그리기만 한다(입력 처리 없음).
///
public sealed class SelectionOverlayViewModel : ViewModelBase
{
#region Member Fields
/// 핸들 한 변 크기(논리 px)
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;
private double zoom = 1;
#endregion
#region Properties
/// 선택 박스 표시 여부
public bool HasSelection { get => hasSelection; set => SetProperty(ref hasSelection, value); }
/// 선택 박스 X(월드)
public double SelX { get => selX; set => SetProperty(ref selX, value); }
/// 선택 박스 Y(월드)
public double SelY { get => selY; set => SetProperty(ref selY, value); }
/// 선택 박스 너비
public double SelW { get => selW; set => SetProperty(ref selW, value); }
/// 선택 박스 높이
public double SelH { get => selH; set => SetProperty(ref selH, value); }
/// 리사이즈 핸들 표시 여부(잠금 선택 시 숨김)
public bool ShowHandles { get => showHandles; set => SetProperty(ref showHandles, value); }
/// 핸들 8개(월드 좌표)
public ObservableCollection Handles { get; } = new();
/// 마퀴 표시 여부
public bool HasMarquee { get => hasMarquee; set => SetProperty(ref hasMarquee, value); }
/// 마퀴 X(월드)
public double MarX { get => marX; set => SetProperty(ref marX, value); }
/// 마퀴 Y(월드)
public double MarY { get => marY; set => SetProperty(ref marY, value); }
/// 마퀴 너비
public double MarW { get => marW; set => SetProperty(ref marW, value); }
/// 마퀴 높이
public double MarH { get => marH; set => SetProperty(ref marH, value); }
/// 정렬 가이드선 목록
public ObservableCollection Guides { get; } = new();
/// 간격 균등 표시 목록
public ObservableCollection Spacings { get; } = new();
///
/// 캔버스 줌 — 오버레이는 줌이 걸린 층에 얹혀 있어서, 선을 화면 1px 로 유지하려면
/// 두께에 이 역수를 걸어야 한다. 갈고리 길이도 같은 이유로 이걸 쓴다.
///
public double Zoom
{
get => zoom;
set
{
if (SetProperty(ref zoom, value <= 0 ? 1 : value))
{
OnPropertyChanged(nameof(InverseZoom));
}
}
}
/// 화면 1px 에 해당하는 월드 길이
public double InverseZoom => 1.0 / zoom;
/// 탭순서 배지 목록(탭순서 편집 모드 전용)
public ObservableCollection TabBadges { get; } = new();
///
/// 잠긴 컨트롤의 테두리 — 편집 크롬이라 종이에는 그려지지 않는다.
///
/// 이 층에 두는 이유가 있다. 종이 층(PageView)에 표시를 넣으면 --db-render 게이트가
/// 그것을 찍어 md5 가 깨진다 — 그 게이트는 레거시와 픽셀이 같다는 것을 잠그고 있다.
///
/// 표시가 필요한 이유: 숨김은 0.25 투명이라 티가 나는데 잠금은 아무 표시가 없었다.
/// 클릭도 드래그도 안 되는데 이유가 화면에 없으니 "왜 이것만 안 잡히지"만 남는다.
///
public ObservableCollection LockMarks { get; } = new();
#endregion
#region Methods
/// 선택 박스/핸들 갱신 — bbox 는 월드 좌표
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 });
}
}
/// 핸들 중심 좌표 8개(0=NW 시계방향)
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),
};
/// 마퀴 갱신 — null 이면 숨김
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;
}
/// 가이드·간격 표시 지우기
public void ClearGuides()
{
if (Guides.Count > 0)
{
Guides.Clear();
}
if (Spacings.Count > 0)
{
Spacings.Clear();
}
}
///
/// 스냅 결과를 표시형으로 — 계산은 가 끝냈고
/// 여기서는 그리기 좌표만 굽는다.
///
/// 내용이 이전과 같으면 컬렉션을 건드리지 않는다. 드래그는 프레임마다 들어오는데
/// 매번 Clear+Add 하면 ItemsControl 이 컨테이너를 통째로 다시 만든다.
///
public void SetGuides(IReadOnlyList guides, IReadOnlyList spacings)
{
if (!SameGuides(guides))
{
Guides.Clear();
foreach (var guide in guides)
{
Guides.Add(guide.IsVertical
? new GuideLineInfo { X1 = guide.Position, X2 = guide.Position, Y1 = guide.Start, Y2 = guide.End }
: new GuideLineInfo { Y1 = guide.Position, Y2 = guide.Position, X1 = guide.Start, X2 = guide.End });
}
}
if (SameSpacings(spacings))
{
return;
}
Spacings.Clear();
// 갈고리는 화면 4px — 줌이 걸린 층이라 월드 길이로 환산해 굽는다
var cap = 4 * InverseZoom;
foreach (var mark in spacings)
{
var mid = (mark.Start + mark.End) / 2;
var text = FormatGap(mark.Gap);
// 칩은 좌상단 기준으로 놓이므로 중심 보정을 여기서 넣는다(뷰 설명 참조).
// 폭은 글꼴을 재지 않고 글자 수로 어림한다 — 1~4자짜리 숫자라 오차가 눈에 안 띈다.
var halfWidth = (4 + 3.4 * text.Length) * InverseZoom;
var halfHeight = 7 * InverseZoom;
Spacings.Add(mark.IsHorizontal
? new SpacingMarkInfo
{
X1 = mark.Start, Y1 = mark.Cross, X2 = mark.End, Y2 = mark.Cross,
CapAX1 = mark.Start, CapAY1 = mark.Cross - cap, CapAX2 = mark.Start, CapAY2 = mark.Cross + cap,
CapBX1 = mark.End, CapBY1 = mark.Cross - cap, CapBX2 = mark.End, CapBY2 = mark.Cross + cap,
Text = text, TextX = mid - halfWidth, TextY = mark.Cross - halfHeight,
}
: new SpacingMarkInfo
{
X1 = mark.Cross, Y1 = mark.Start, X2 = mark.Cross, Y2 = mark.End,
CapAX1 = mark.Cross - cap, CapAY1 = mark.Start, CapAX2 = mark.Cross + cap, CapAY2 = mark.Start,
CapBX1 = mark.Cross - cap, CapBY1 = mark.End, CapBX2 = mark.Cross + cap, CapBY2 = mark.End,
Text = text, TextX = mark.Cross - halfWidth, TextY = mid - halfHeight,
});
}
}
private static string FormatGap(double gap) => $"{Math.Round(gap):0}";
private bool SameGuides(IReadOnlyList guides)
{
if (Guides.Count != guides.Count)
{
return false;
}
for (var i = 0; i < guides.Count; i++)
{
var a = Guides[i];
var b = guides[i];
var x1 = b.IsVertical ? b.Position : b.Start;
var y1 = b.IsVertical ? b.Start : b.Position;
var x2 = b.IsVertical ? b.Position : b.End;
var y2 = b.IsVertical ? b.End : b.Position;
if (Math.Abs(a.X1 - x1) > 0.001 || Math.Abs(a.Y1 - y1) > 0.001
|| Math.Abs(a.X2 - x2) > 0.001 || Math.Abs(a.Y2 - y2) > 0.001)
{
return false;
}
}
return true;
}
private bool SameSpacings(IReadOnlyList spacings)
{
if (Spacings.Count != spacings.Count)
{
return false;
}
for (var i = 0; i < spacings.Count; i++)
{
var a = Spacings[i];
var b = spacings[i];
var x1 = b.IsHorizontal ? b.Start : b.Cross;
var y1 = b.IsHorizontal ? b.Cross : b.Start;
if (Math.Abs(a.X1 - x1) > 0.001 || Math.Abs(a.Y1 - y1) > 0.001)
{
return false;
}
}
return true;
}
#endregion
}