정렬 — 기준은 언제나 '담고 있는 것'. 그리고 속성 패널에 정렬 행
"폼 기준으로 정렬"이 없었다. 정확히는 정렬 자체가 2개 이상에서만 동작했고 기준은 늘 선택 영역이라, 컨트롤 하나를 페이지 가운데 놓을 방법이 메뉴 어디에도 없었다. 규칙을 한 문장으로 바꿨다 — 기준은 그것을 담고 있는 것이다. 선택이 2개 이상이면 선택 영역이 그 자리를 대신하고, 1개면 담고 있는 것 — 컨테이너 안이면 컨테이너, 최상위면 페이지 — 이 기준이 된다. 피그마와 같다. 컨테이너 자식을 페이지 기준으로 맞추지 않는 이유. X/Y 는 부모 상대좌표라 '페이지 왼쪽'에 맞추면 음수가 되어 부모 밖으로 나간다. 디자이너 캔버스는 클리핑을 안 해서 멀쩡히 보이지만 레거시 런타임에서는 부모가 잘라먹는다 — 화면과 인쇄가 갈라지고 눈으로는 못 잡는 종류의 버그다. 같이 고친 것들: · 페이지를 넘는 선택의 세로 정렬을 거부한다. 다른 종이의 '위 맞춤'에는 뜻이 없고, 예전에는 실제로 깨졌다 — bbox 는 첫 페이지 월드 Y 인데 offset 은 자기 페이지 OffsetY 라 vm.Y 가 큰 음수가 되어 컨트롤이 제 종이 위 허공으로 날아갔다. 가로는 모든 페이지의 X 원점이 0 이라 그대로 허용한다. · 부모와 자식을 함께 선택한 경우 자식을 대상에서 뺀다. 부모를 옮기면 자식 월드 좌표가 따라 움직이는데 자식에게도 절대좌표를 대입하면 두 번 옮겨진다(레이어 목록에서 만들 수 있는 선택이다). · 반올림을 AwayFromZero 로 고정했다. 기본 ToEven 은 322.5→322, 323.5→324 로 방향이 값에 따라 뒤집힌다 — 폭이 제각각인 라벨 다섯 개를 가운데 맞춤하면 어떤 건 322, 어떤 건 323 에 앉는다. (스냅에서 반올림을 뺀 것과 반대로 보이지만 이유가 다르다. 거긴 가이드선이 모서리에서 뜨는 게 보이기 때문이고, 정렬은 선을 긋지 않는다. 저장 포맷은 어차피 정수다.) · 변경이 실제로 생길 때만 실행취소를 찍는다. 버튼으로 노출되면 연타되는데, 무조건 찍으면 빈 되돌리기 단계가 쌓이고 '저장 안 한 변경'으로 표시된다. 산술은 Core/Layout/AlignSolver 로 뺐다. 짧지만 경계가 잔소리 같은 규칙 덩어리라(반올림 방향, 기준보다 넓은 항목, 원점이 큰 둘째 장) 디자이너 쪽에서 검사하면 케이스마다 실행취소 스냅샷을 하나씩 먹는다. 단위 테스트 10건이 그걸 공짜로 고정한다. 속성 패널 정렬 행. [200]SheetMe 의 inspector-wireframe.html 을 따랐다 — 30x26 버튼 6개, radius 6, 사이 2px, 가로 축과 세로 축 사이 8px 틈, 아이콘은 stroke 1.5. LucideIcons 의 align-obj-* 6개는 등록만 되고 아무 데서도 안 쓰이던 죽은 항목이라 참조 도형으로 갈아 끼웠다(사각형 둘 + 기준선 → 사각형 하나 + 기준선). 좌표는 24격자다 — Build 가 Canvas 를 24x24 로 하드코딩해서, 참조의 16격자 좌표를 그대로 넣으면 아이콘이 좌상단 2/3 크기로 쏠린다. 크기 18 에서 stroke 2 × 18/24 = 1.5px 로 참조와 같은 굵기가 나온다. 버튼 스타일은 새로 만들었다. SegmentToggle 은 치수가 같지만 채워진 상태 토글 룩이고, 정렬은 상태가 아니라 액션이라 눌려 있는 것처럼 보이면 안 된다 — 채움 없이 호버만 남겼다. 표시 조건에 HasSelection 을 쓰지 않았다. 그건 페이지 속성 모드에서도 true 라 정렬할 컨트롤이 없는데 바가 뜬다. 선택 0 에서는 접어야 하는데, 안 접으면 '컨트롤을 선택하면…' 안내문 뒤에 버튼 줄이 그대로 남는다(같은 Grid 에 겹쳐 그려진다). 툴팁이 지금 무엇을 기준으로 정렬하는지 말한다 — "왼쪽 맞춤 — 페이지 기준" / "— Panel1 기준" / "— 선택 영역 기준". 세로 3버튼은 페이지를 넘는 선택에서 비활성되고 그 이유를 툴팁에 적는다. 아이콘만 있는 버튼이라 AutomationProperties.Name 을 붙였다 — 이 저장소에 버튼 자동화 속성 전례가 없어 새로 여는 관례다. 메뉴는 그대로 두되 툴팁으로 전제를 적었고, '페이지 가로/세로 가운데'는 헤더에 '묶음째'를 붙였다. 선택 1개에서는 CenterH 와 결과가 같아지고, 다중에서만 갈리기 때문이다(그건 상대 배치를 보존한 채 묶음을 옮기고, CenterH 는 각자 가운데로 모은다). 분배(간격 균등) 버튼은 넣지 않았다. 참조 와이어프레임에 없고, 3개 이상에서만 살아서 실사용 대부분(1~2개 선택)에 두 칸이 죽은 채 남아 줄이 고장 난 것처럼 읽힌다. --dialog-shots 에 선택 상태별 인스펙터 3장(12c/12d/12e)을 더했다. 정렬 바가 선택 0 과 페이지 속성 모드에서 사라지는지는 수치로 못 잡는다. 산출물 51 → 57. 기존 인스펙터 5장은 정렬 행만큼 아래로 밀린다 — 정상 diff 다. 알려진 별건(이번에 안 고침): 정렬 후 Ctrl+Z 하면 선택이 풀린다. Undo 가 문서를 통째로 갈아끼우며 Selection.Clear 하기 때문이고, 정렬은 '왼쪽 → 아니네 → 가운데'로 여러 번 시도하는 조작이라 버튼으로 노출되면서 드러난다. Id 기반 선택 복원이 필요해 별도로 다룬다. 게이트: 테스트 267/267(신규 10), --edit-smoke 234건 0실패(정렬 검사 11건 신규), --query-popup 17/17, --maxrect 10/10, --snap-shots 7종 0실패, --db-smoke 3000 diff 0·예외 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일, --dialog-shots 57파일. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ba838ae54a
commit
26f696ab00
@@ -1463,35 +1463,138 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
Selection.Set(page.Controls.Where(c => !c.Model.Hidden), page.Controls.LastOrDefault());
|
||||
}
|
||||
|
||||
/// <summary>선택 정렬 — 선택 bbox 기준(Left/Right/Top/Bottom/CenterH/CenterV)</summary>
|
||||
/// <summary>
|
||||
/// 선택 정렬 — <b>기준은 언제나 '담고 있는 것'</b>이다.
|
||||
///
|
||||
/// 선택이 2개 이상이면 선택 영역이 그 자리를 대신하고, 1개면 그 컨트롤을 담고 있는 것 —
|
||||
/// 컨테이너 안이면 컨테이너, 최상위면 페이지 — 이 기준이 된다. 피그마와 같은 규칙이다.
|
||||
///
|
||||
/// <b>컨테이너 자식을 페이지 기준으로 맞추지 않는 이유.</b> X/Y 는 부모 상대좌표라
|
||||
/// "페이지 왼쪽"에 맞추면 음수가 되어 부모 밖으로 나간다. 디자이너 캔버스는 클리핑을 안 해서
|
||||
/// 멀쩡히 보이지만 레거시 런타임에서는 부모가 잘라먹는다 — 화면과 인쇄가 갈라지고 눈으로 못 잡는다.
|
||||
/// </summary>
|
||||
public void Align(string mode)
|
||||
{
|
||||
if (Selection.Items.Count < 2)
|
||||
if (Selection.Items.Count == 0 || SheetMe.Core.Layout.AlignSolver.Parse(mode) is not { } direction)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var bbox = SelectionWorldBounds();
|
||||
if (bbox is null)
|
||||
|
||||
// 부모와 그 자식을 함께 선택할 수 있다(레이어 목록). 부모를 옮기면 자식 월드 좌표가 따라 움직이는데
|
||||
// 자식에게도 절대좌표를 대입하면 두 번 옮겨져 어긋난다 — 조상이 선택에 있는 것은 뺀다.
|
||||
var targets = Selection.Items.Where(vm => !HasSelectedAncestor(vm)).ToList();
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.Snapshot();
|
||||
foreach (var vm in Selection.Items)
|
||||
|
||||
// 다른 종이의 '위 맞춤'에는 뜻이 없다. 예전에는 이 경우 컨트롤이 제 종이 위 허공으로 날아갔다
|
||||
// (bbox 는 첫 페이지 월드 Y 인데 offset 은 자기 페이지 OffsetY 라 vm.Y 가 큰 음수가 됐다).
|
||||
if (SheetMe.Core.Layout.AlignSolver.IsVertical(direction)
|
||||
&& targets.Select(PageOf).Distinct().Count() > 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ReferenceFor(targets) is not { } reference)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 버튼으로 노출되면 사람은 연타한다. 무조건 스냅샷을 찍으면 빈 실행취소 단계가 쌓이고
|
||||
// '저장 안 한 변경'으로 표시된다 — 실제로 움직일 때만 찍는다.
|
||||
var moves = new List<(ControlViewModel Vm, double X, double Y)>(targets.Count);
|
||||
var changed = false;
|
||||
foreach (var vm in targets)
|
||||
{
|
||||
var offset = ParentWorldOffset(vm);
|
||||
switch (mode)
|
||||
{
|
||||
case "Left": vm.X = Math.Round(bbox.Value.Left - offset.X); break;
|
||||
case "Right": vm.X = Math.Round(bbox.Value.Right - vm.Width - offset.X); break;
|
||||
case "Top": vm.Y = Math.Round(bbox.Value.Top - offset.Y); break;
|
||||
case "Bottom": vm.Y = Math.Round(bbox.Value.Bottom - vm.Height - offset.Y); break;
|
||||
case "CenterH": vm.X = Math.Round(bbox.Value.Left + (bbox.Value.Width - vm.Width) / 2 - offset.X); break;
|
||||
case "CenterV": vm.Y = Math.Round(bbox.Value.Top + (bbox.Value.Height - vm.Height) / 2 - offset.Y); break;
|
||||
}
|
||||
var world = new SheetMe.Core.Layout.SnapRect(
|
||||
offset.X + vm.X, offset.Y + vm.Y, vm.Width, vm.Height);
|
||||
var aligned = SheetMe.Core.Layout.AlignSolver.Apply(world, reference, direction);
|
||||
var x = aligned.X - offset.X;
|
||||
var y = aligned.Y - offset.Y;
|
||||
changed |= x != vm.X || y != vm.Y;
|
||||
moves.Add((vm, x, y));
|
||||
}
|
||||
if (!changed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Undo.Snapshot();
|
||||
foreach (var (vm, x, y) in moves)
|
||||
{
|
||||
vm.X = x;
|
||||
vm.Y = y;
|
||||
}
|
||||
// 페이지 소속은 바뀌지 않는다(세로 다중 페이지는 위에서 거부했다) — ReassignPagesAfterMove 는 부르지 않는다
|
||||
Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
/// <summary>정렬 기준 사각형(월드) — 여럿이면 선택 영역, 하나면 그것을 담고 있는 것</summary>
|
||||
private SheetMe.Core.Layout.SnapRect? ReferenceFor(IReadOnlyList<ControlViewModel> targets)
|
||||
{
|
||||
if (targets.Count >= 2)
|
||||
{
|
||||
if (SelectionWorldBounds() is not { } bbox)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new SheetMe.Core.Layout.SnapRect(bbox.X, bbox.Y, bbox.Width, bbox.Height);
|
||||
}
|
||||
|
||||
var only = targets[0];
|
||||
if (only.Parent is { } parent)
|
||||
{
|
||||
var rect = WorldBoundsOf(parent);
|
||||
return new SheetMe.Core.Layout.SnapRect(rect.X, rect.Y, rect.Width, rect.Height);
|
||||
}
|
||||
// 스냅(SnapEngine)이 쓰는 용지 사각형 표현과 글자 그대로 같아야 한다 —
|
||||
// 갈라지면 가이드선이 가리키는 자리와 정렬 결과가 어긋난다
|
||||
return PageOf(only) is { } page
|
||||
? new SheetMe.Core.Layout.SnapRect(0, page.OffsetY, page.WidthDip, page.HeightDip)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>부모 체인에 선택된 것이 있는가</summary>
|
||||
private bool HasSelectedAncestor(ControlViewModel viewModel)
|
||||
{
|
||||
var parent = viewModel.Parent;
|
||||
while (parent is not null)
|
||||
{
|
||||
if (Selection.Items.Contains(parent))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
parent = parent.Parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>정렬 기준을 사람 말로 — 인스펙터 툴팁이 이걸 물고 있다</summary>
|
||||
public string AlignScopeText()
|
||||
{
|
||||
var items = Selection.Items;
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
if (items.Count >= 2)
|
||||
{
|
||||
return items.Select(PageOf).Distinct().Count() > 1
|
||||
? "선택 영역 기준 (여러 페이지)"
|
||||
: "선택 영역 기준";
|
||||
}
|
||||
var only = items[0];
|
||||
return only.Parent is { } parent
|
||||
? $"{(parent.Id.Length > 0 ? parent.Id : "부모")} 기준"
|
||||
: "페이지 기준";
|
||||
}
|
||||
|
||||
/// <summary>세로 정렬이 가능한가 — 여러 페이지에 걸친 선택에서는 뜻이 없다</summary>
|
||||
public bool CanAlignVertical()
|
||||
=> Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
|
||||
|
||||
/// <summary>
|
||||
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
|
||||
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
|
||||
|
||||
Reference in New Issue
Block a user