사용성 2순위 — 있는 명령에 닿는 길을 낸다
1순위가 '틀린 것 고치기'였다면 여기는 <b>이미 있는 명령에 닿는 길</b>이다. 새 패널·새 화면 없이 커맨드·단축키·메뉴 층에서 끝난다. ## 선택 확장 4종 — 정렬 8종의 실효 사용률을 통째로 끌어올린다 정렬 6종·간격 4종·같은 크기가 다 있는데, 그 명령들이 전제하는 '올바른 선택'을 만들 수단이 없었다. 표 서식에서 가장 흔한 편집이 "이 행의 입력칸 8개를 조금 높이고 아래로 붙이자"인데, 그 8개를 고르는 길이 <b>Ctrl+클릭 8번뿐</b>이다. 마퀴는 교차 판정이라 행 띠를 스치는 것이 전부 딸려 온다 — 실측 787건에서 행 무리의 94.2%에 침입자가 있고 그 수가 중앙값 7개·p90 41개다. 즉 지금까지는 "드래그 → 딸려온 것 빼기"가 매번 앞에 붙었다. 주 선택 기준으로 같은 행/열/종류/크기인 <b>형제</b>를 선택에 더한다(Alt+1~4, 우클릭 메뉴 맨 위). 범위를 형제로 한 것은 정렬 명령이 쓰는 범위와 같아야 결과를 예측할 수 있어서다. 2px 오차를 허용한다. 정확히 같은 값만 묶으면 눈으로 맞춘 서식에서 <b>일부만</b> 잡히고, 8개 중 5개를 정렬해 놓고 나머지가 남은 것을 못 알아채는 것이 가장 나쁜 결과다. 격자 크기에 묶지 않았다 — 격자를 바꿨다고 선택 규칙이 달라지면 예측할 수 없다. 잠긴 것은 뺀다. 편집하려고 넓히는 선택인데 안 움직이는 것이 섞이면 정렬을 눌렀을 때 "왜 이건 그대로지"가 된다. ## 줌 단축키 — 커맨드는 처음부터 있었고 키만 없었다 8pt 글자를 확인하려 200% 로 올렸다가 전체 배치를 보려면, 캔버스에서 손을 떼고 <b>화면 맨 아래 가운데</b> 플로팅 바의 배율 버튼을 눌러 팝업을 열고 '화면 맞춤'을 골라야 했다. 확대·축소조차 Ctrl+휠뿐이라 트랙패드에서는 배율을 바꿀 키보드 수단이 아예 없었다. Ctrl+0(100%) · Ctrl+1(화면 맞춤) · Ctrl+± 를 <b>창</b> InputBindings 에 건다. 캔버스 키 처리기에 두면 안 된다 — 그쪽은 포커스가 TextBox/ComboBox 면 통째로 비켜서므로 인스펙터 값을 고친 직후에 줌 키가 죽는다. ## Ctrl+방향키로 크기 — 위치에만 있던 미세 조정의 짝 입력칸 폭을 3px 늘리려면 오른쪽 인스펙터 W 칸으로 가서 숫자를 읽고 3 을 더해 입력했다 — 캔버스와 300px 떨어진 패널 사이를 마우스로 왕복한다. 마우스 핸들은 판정 반경이 화면 6px 이라 작은 컨트롤에서 3px 를 정확히 집기 어렵고, 스냅이 붙으면 원하는 값에 못 선다. 넛지와 같은 400ms 코얼레스를 쓴다. 선은 LineGeometry 를 태워 두께 축이 늘어나지 않게 막는다 — 허용하면 디자이너에만 두꺼운 선이 보이고 EMR 은 원래 두께로 그린다. ## 잠금·숨김을 캔버스에서, 그리고 잠긴 것을 보이게 배경 괘선 40개를 잠가 두고 그 위에서 라벨만 만지고 싶을 때, 지금은 좌측 패널을 레이어 탭으로 바꾸고 트리에서 40행을 찾아 13px 자물쇠를 40번 눌러야 했다. 마퀴로 40개를 잡아 놔도 한 번에 잠글 수단이 없었다. Ctrl+L / Ctrl+Shift+H 와 우클릭 메뉴를 붙인다. <b>표시가 없던 것이 더 문제였다.</b> 숨김은 0.25 투명이라 티가 나는데 잠금은 아무 표시가 없어, 클릭도 드래그도 안 되는 이유가 화면에 없었다 — "왜 이것만 안 잡히지"만 남는다. 점선 테두리를 <b>오버레이 층</b>에 그린다. 종이 층(PageView)에 넣으면 db-render 게이트가 그것을 찍어 md5 가 깨진다 — 레거시와의 픽셀 동일성을 잠그는 게이트다. 메뉴 이름은 '선택 잠그기'로 한정했다. <b>해제는 캔버스에서 못 한다</b> — 히트 판정이 잠긴 것을 후보에서 빼므로 우클릭조차 닿지 않는다. 해제 경로는 레이어 패널이다. ## Ctrl+\ — 유일한 레이아웃 변경 좌 248 + 스플리터 10 + 우 300 = 558px 가 <b>항상</b> 점유된다. MinWidth 180/250 때문에 스플리터를 끝까지 밀어도 사라지지 않고, 보기 메뉴에도 항목이 없었다. 가로 용지를 120% 로만 올려도 캔버스에 가로 스크롤이 생기는데 '종이만 보기' 상태가 없다. 좌우를 <b>함께</b> 접는다. 개별 토글 두 개를 두지 않았다 — 목적이 하나라 키도 하나여야 손이 기억한다. MinWidth 도 함께 0 으로 내려야 실제로 사라진다(Width=0 만으로는 최소폭이 이긴다). 펼친 폭은 따로 기억한다 — 접힌 0 을 사용자가 정한 폭으로 착각하면 다음에 못 되돌린다. F4(인스펙터 진입)를 누르면 접힌 인스펙터를 먼저 편다. 레이아웃은 이것 말고 바꾸지 않았다. 좌 248 / 우 300 은 이미 측정된 값이고 (레이어 행 최소치, 인스펙터 X|Y 한 줄 최소치) 라벨 1개 선택 시 자주 쓰는 4종이 0 스크롤·0 클릭이다. 컨트롤 1,000개짜리 서식에서 트리와 속성을 동시에 봐야 하는 작업이라 떠 있는 패널로 바꾸면 겹침·재배치 비용만 생긴다. ## 판정 - 단위 시험 388 - edit-smoke 376건 전건 통과 (선택 확장 7건 · 크기 조절 2건 · 잠금 5건 추가) - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 (잠금 표시가 종이에 안 나온다는 증거) - <b>--cleartype 은 이번에 판정 불가다</b> — 게이트가 스스로 "캡처 실패, 색 픽셀 0" 이라고 보고한다. 이전 커밋에서 stash 로 되돌려 돌려도 같은 결과라 이 변경 탓이 아니다(환경 문제). 화면 캡처가 되는 상태에서 다시 확인할 것. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fccd441193
commit
020595f8d4
@@ -50,6 +50,13 @@ public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
|
||||
|
||||
switch (key)
|
||||
{
|
||||
// Ctrl+방향키는 <b>크기</b>다 — 위치에는 미세 조정 수단이 있는데 크기만 없었다.
|
||||
// 3px 늘리려고 300px 떨어진 인스펙터 W 칸까지 마우스로 왕복하던 것을 없앤다.
|
||||
// Shift 를 함께 누르면 위치와 같은 큰 걸음(step)을 쓴다.
|
||||
case Key.Left when ctrl: designer.ResizeSelection(-step, 0); break;
|
||||
case Key.Right when ctrl: designer.ResizeSelection(step, 0); break;
|
||||
case Key.Up when ctrl: designer.ResizeSelection(0, -step); break;
|
||||
case Key.Down when ctrl: designer.ResizeSelection(0, step); break;
|
||||
case Key.Left: designer.NudgeSelection(-step, 0); break;
|
||||
case Key.Right: designer.NudgeSelection(step, 0); break;
|
||||
case Key.Up: designer.NudgeSelection(0, -step); break;
|
||||
@@ -78,6 +85,19 @@ public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
|
||||
case Key.G when ctrl && shift: designer.UngroupSelection(); break;
|
||||
case Key.G when ctrl: designer.GroupSelection(); break;
|
||||
|
||||
// 선택 확장 — 정렬·간격 명령이 전제하는 '올바른 선택'을 만드는 수단.
|
||||
// 마퀴는 교차 판정이라 행 띠를 스치는 것이 전부 딸려 오고(실측 침입자 중앙값 7개),
|
||||
// 그래서 지금까지는 "드래그 → 딸려온 것 빼기"가 매번 붙었다.
|
||||
case Key.D1 when alt: designer.SelectSame(DesignerViewModel.SameKind.Row); break;
|
||||
case Key.D2 when alt: designer.SelectSame(DesignerViewModel.SameKind.Column); break;
|
||||
case Key.D3 when alt: designer.SelectSame(DesignerViewModel.SameKind.Type); break;
|
||||
case Key.D4 when alt: designer.SelectSame(DesignerViewModel.SameKind.Size); break;
|
||||
|
||||
// 잠금·숨김 — 상태와 레이어 패널의 자물쇠는 있었는데 캔버스에서 켤 방법이 없었다.
|
||||
// 배경 괘선 40개를 잠그려면 레이어 트리에서 40번 눌러야 했다.
|
||||
case Key.L when ctrl: designer.ToggleLockSelection(); break;
|
||||
case Key.H when ctrl && shift: designer.ToggleHiddenSelection(); break;
|
||||
|
||||
// 배치 명령 — 전에는 단축키가 <b>0개</b>였고 전부 로고 아래 4단 메뉴 안에만 있었다.
|
||||
// 20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 좌상단까지 갔다.
|
||||
// 자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다.
|
||||
|
||||
@@ -1095,6 +1095,88 @@ public static class EditSmoke
|
||||
$"ValueText='{choiceRow.ValueText}' IsMixed={choiceRow.IsMixed}");
|
||||
}
|
||||
|
||||
// 15-a0) 선택 확장 — 정렬·간격 명령이 전제하는 '올바른 선택'을 만드는 수단.
|
||||
//
|
||||
// 마퀴는 <b>교차</b> 판정이라 행 띠를 스치는 것이 전부 딸려 온다(실측 787건:
|
||||
// 행 무리의 94.2%에 침입자, 중앙값 7개·p90 41개). 그래서 지금까지는
|
||||
// "드래그 → 딸려온 것 Ctrl+클릭으로 빼기"가 매번 앞에 붙었다.
|
||||
{
|
||||
var pick = new DesignerViewModel(new FormDesignDataBusiness().CreateNew());
|
||||
ControlViewModel Put(string type, double x, double y, double w, double h)
|
||||
{
|
||||
pick.AddControlAt(type, new Point(x, y));
|
||||
var vm = pick.Pages[0].Controls[^1];
|
||||
vm.X = x; vm.Y = y; vm.Width = w; vm.Height = h;
|
||||
return vm;
|
||||
}
|
||||
// 한 줄에 셋(Y 동일), 그 아래 하나, 그리고 같은 열에 하나
|
||||
var row1 = Put("TextBox", 100, 200, 80, 20);
|
||||
var row2 = Put("TextBox", 200, 200, 80, 20);
|
||||
var row3 = Put("Label", 300, 201, 60, 20); // 1px 어긋남 — 눈으로 맞춘 서식의 현실
|
||||
var below = Put("TextBox", 100, 260, 80, 20); // 같은 열(X=100)
|
||||
var far = Put("Label", 500, 400, 60, 20);
|
||||
|
||||
pick.Selection.SetSingle(row1);
|
||||
var added = pick.SelectSame(DesignerViewModel.SameKind.Row);
|
||||
Check("선택 확장: 같은 행을 잡는다", pick.Selection.Items.Count == 3
|
||||
&& pick.Selection.Items.Contains(row2) && pick.Selection.Items.Contains(row3),
|
||||
$"{pick.Selection.Items.Count}개 (더해진 {added})");
|
||||
Check("선택 확장: 1px 어긋난 것도 같은 행으로 본다", pick.Selection.Items.Contains(row3),
|
||||
"눈으로 맞춘 서식에서 일부만 잡히면 정렬 후에도 남은 것을 못 알아챈다");
|
||||
Check("선택 확장: 딴 데 있는 것은 안 잡힌다", !pick.Selection.Items.Contains(far));
|
||||
|
||||
pick.Selection.SetSingle(row1);
|
||||
pick.SelectSame(DesignerViewModel.SameKind.Column);
|
||||
Check("선택 확장: 같은 열을 잡는다", pick.Selection.Items.Count == 2
|
||||
&& pick.Selection.Items.Contains(below), $"{pick.Selection.Items.Count}개");
|
||||
|
||||
pick.Selection.SetSingle(row1);
|
||||
pick.SelectSame(DesignerViewModel.SameKind.Type);
|
||||
Check("선택 확장: 같은 종류를 잡는다",
|
||||
pick.Selection.Items.All(c => c.Type == "TextBox") && pick.Selection.Items.Count == 3,
|
||||
$"{pick.Selection.Items.Count}개");
|
||||
|
||||
pick.Selection.SetSingle(row1);
|
||||
pick.SelectSame(DesignerViewModel.SameKind.Size);
|
||||
Check("선택 확장: 같은 크기를 잡는다", pick.Selection.Items.Count == 3,
|
||||
$"{pick.Selection.Items.Count}개 (80×20 인 것)");
|
||||
|
||||
// 잠긴 것은 빼야 한다 — 편집하려고 넓히는 선택인데 안 움직이는 것이 섞이면
|
||||
// 정렬을 눌렀을 때 "왜 이건 그대로지"가 된다
|
||||
row2.Model.Locked = true;
|
||||
pick.Selection.SetSingle(row1);
|
||||
pick.SelectSame(DesignerViewModel.SameKind.Row);
|
||||
Check("선택 확장: 잠긴 것은 넣지 않는다", !pick.Selection.Items.Contains(row2),
|
||||
$"{pick.Selection.Items.Count}개");
|
||||
row2.Model.Locked = false;
|
||||
|
||||
// 크기 조절 — 위치에는 미세 조정이 있는데 크기만 없었다
|
||||
pick.Selection.SetSingle(row1);
|
||||
var w0 = row1.Width;
|
||||
pick.ResizeSelection(3, 0);
|
||||
Check("크기 조절: Ctrl+방향키로 1px 씩 넓힌다", Math.Abs(row1.Width - (w0 + 3)) < 0.5,
|
||||
$"{w0} → {row1.Width}");
|
||||
pick.ResizeSelection(-9999, 0);
|
||||
Check("크기 조절: 1 아래로는 안 내려간다", row1.Width >= 1, $"{row1.Width}");
|
||||
|
||||
// 잠금 토글 — 상태와 레이어 자물쇠는 있었는데 캔버스에서 켤 방법이 없었다
|
||||
pick.Selection.Set(new[] { row1, row2 }.ToList(), row1);
|
||||
pick.ToggleLockSelection();
|
||||
Check("잠금: 선택 전체를 한 번에 잠근다", row1.Model.Locked && row2.Model.Locked);
|
||||
Check("잠금: 잠긴 것이 화면에 표시된다", pick.Overlay.LockMarks.Count >= 2,
|
||||
$"표시 {pick.Overlay.LockMarks.Count}개");
|
||||
pick.ToggleLockSelection();
|
||||
Check("잠금: 다시 누르면 풀린다", !row1.Model.Locked && !row2.Model.Locked);
|
||||
Check("잠금: 풀면 표시도 사라진다", pick.Overlay.LockMarks.Count == 0,
|
||||
$"표시 {pick.Overlay.LockMarks.Count}개");
|
||||
var lockedId = row1.Id;
|
||||
pick.Undo.Undo();
|
||||
// 되돌리기는 문서를 통째로 갈아끼운다 — 옛 VM 참조는 죽는다. Id 로 다시 찾는다.
|
||||
var revived = pick.Pages[0].Controls.FirstOrDefault(c => c.Id == lockedId);
|
||||
Check("잠금: 되돌리기로 원복된다", revived is not null && revived.Model.Locked,
|
||||
revived is null ? "컨트롤을 못 찾음" : $"Locked={revived.Model.Locked}");
|
||||
}
|
||||
|
||||
// 15-b0) 잠긴 것 위는 '빈 곳'이 아니다 — 우클릭이 선택을 지우면 안 된다.
|
||||
//
|
||||
// 우클릭이 커서 아래를 선택하도록 고치면서 생긴 함정이다. HitTestControl 은 잠금·숨김을
|
||||
|
||||
@@ -331,6 +331,15 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
UngroupCommand = new Command((sender, e) => UngroupSelection());
|
||||
CutCommand = new Command((sender, e) => CutSelection());
|
||||
SizeToControlCommand = new Command((object param) => SizeToControl(param as string ?? "Both"));
|
||||
SelectSameCommand = new Command((object param) =>
|
||||
{
|
||||
if (Enum.TryParse<SameKind>(param as string, out var kind))
|
||||
{
|
||||
SelectSame(kind);
|
||||
}
|
||||
});
|
||||
LockCommand = new Command((sender, e) => ToggleLockSelection());
|
||||
HideCommand = new Command((sender, e) => ToggleHiddenSelection());
|
||||
SpacingCommand = new Command((object param) => AdjustSpacing(param as string ?? string.Empty));
|
||||
CenterInPageCommand = new Command((object param) => CenterInPage(param as string ?? "H"));
|
||||
TabOrderCommand = new Command((sender, e) => ToggleTabOrderMode());
|
||||
@@ -388,6 +397,15 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
/// <summary>같은 크기로(파라미터: Both/Width/Height — 기준 컨트롤에 맞춤)</summary>
|
||||
public ICustomCommand? SizeToControlCommand { get; set; }
|
||||
|
||||
/// <summary>같은 행·열·종류·크기로 선택 넓히기 — 인자는 <see cref="SameKind"/> 이름</summary>
|
||||
public ICustomCommand? SelectSameCommand { get; set; }
|
||||
|
||||
/// <summary>선택 잠그기/풀기</summary>
|
||||
public ICustomCommand? LockCommand { get; set; }
|
||||
|
||||
/// <summary>선택 숨기기/보이기</summary>
|
||||
public ICustomCommand? HideCommand { get; set; }
|
||||
|
||||
/// <summary>간격 조절(파라미터: HorizEqual/HorizIncrease/HorizDecrease/HorizConcat/VertEqual/...)</summary>
|
||||
public ICustomCommand? SpacingCommand { get; set; }
|
||||
|
||||
@@ -865,11 +883,42 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
public void RefreshOverlay()
|
||||
{
|
||||
Overlay.UpdateSelection(SelectionWorldBounds(), Selection.Items.Count > 0);
|
||||
RefreshLockMarks();
|
||||
if (Selection.Primary is not null)
|
||||
{
|
||||
Selection.ActivePage = PageOf(Selection.Primary) ?? Selection.ActivePage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 잠긴 컨트롤 표시를 다시 모은다 — 오버레이(편집 크롬) 층에만 그린다.
|
||||
///
|
||||
/// 종이 층에 넣으면 <c>--db-render</c> 게이트가 그것을 찍어 레거시와의 픽셀 동일성이 깨진다.
|
||||
/// 숨김은 0.25 투명이라 티가 나는데 잠금은 아무 표시가 없어, 클릭도 드래그도 안 되는 이유를
|
||||
/// 화면에서 알 수 없었다.
|
||||
/// </summary>
|
||||
private void RefreshLockMarks()
|
||||
{
|
||||
Overlay.LockMarks.Clear();
|
||||
foreach (var page in Pages)
|
||||
{
|
||||
foreach (var vm in ControlsOfPage(page))
|
||||
{
|
||||
if (!vm.Model.Locked || vm.Model.Hidden)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var world = WorldBoundsOf(vm);
|
||||
Overlay.LockMarks.Add(new LockMarkInfo
|
||||
{
|
||||
X = world.X,
|
||||
Y = world.Y,
|
||||
W = Math.Max(1, world.Width),
|
||||
H = Math.Max(1, world.Height),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
@@ -1748,6 +1797,156 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택 확장의 기준 — 같은 줄로 볼 만큼 가까운가.
|
||||
///
|
||||
/// 정확히 같은 값만 묶으면 눈으로 맞춘 서식에서 <b>일부만</b> 잡힌다 — 8개 중 5개를 정렬해
|
||||
/// 놓고 나머지 3개가 남은 것을 못 알아채는 것이 가장 나쁜 결과다. 2px 는 이 서식의
|
||||
/// 컨트롤 높이(대부분 20px 이상)보다 훨씬 작아 이웃 줄을 삼키지 않는다.
|
||||
/// 격자 크기에 묶지 않는다 — 격자를 바꿨다고 선택 규칙이 달라지면 예측할 수 없다.
|
||||
/// </summary>
|
||||
private const double SelectSameTolerance = 2.0;
|
||||
|
||||
/// <summary>선택 확장의 종류 — 우클릭 메뉴·단축키가 공유한다</summary>
|
||||
public enum SameKind
|
||||
{
|
||||
/// <summary>같은 행(Y 가 같다)</summary>
|
||||
Row,
|
||||
/// <summary>같은 열(X 가 같다)</summary>
|
||||
Column,
|
||||
/// <summary>같은 종류(타입이 같다)</summary>
|
||||
Type,
|
||||
/// <summary>같은 크기(너비·높이가 같다)</summary>
|
||||
Size,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 주 선택과 <b>같은 줄·열·종류·크기</b>인 형제를 선택에 더한다 — 반환은 더해진 개수.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 정렬 6종·간격 4종·같은 크기가 이미 다 있는데, 그 명령들이 전제하는
|
||||
/// "올바른 선택"을 만들 수단이 없었다. 표 서식에서 가장 흔한 편집이
|
||||
/// "이 행의 입력칸 8개를 조금 높이고 아래로 붙이자"인데, 그 8개를 고르는 길이
|
||||
/// Ctrl+클릭 8번뿐이다. 마퀴는 <b>교차</b> 판정이라 행 띠를 스치는 것이 전부 딸려 오고,
|
||||
/// 실측(787건)으로 행 무리의 94.2%에 침입자가 있으며 그 수가 중앙값 7개·p90 41개다.
|
||||
/// 즉 지금은 "드래그 → 딸려온 것 빼기"가 매번 붙는다.
|
||||
///
|
||||
/// 범위는 <b>같은 부모의 형제</b>다 — 정렬 명령이 쓰는 범위와 같아야 결과를 예측할 수 있다.
|
||||
/// 순수 계산이라 종이 렌더·저장 형식에 전혀 닿지 않는다.
|
||||
/// </summary>
|
||||
public int SelectSame(SameKind kind)
|
||||
{
|
||||
var anchor = Selection.Primary ?? Selection.Items.FirstOrDefault();
|
||||
var page = (anchor is null ? null : PageOf(anchor)) ?? Selection.ActivePage ?? Pages.FirstOrDefault();
|
||||
if (anchor is null || page is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
static bool Near(double left, double right) => Math.Abs(left - right) <= SelectSameTolerance;
|
||||
|
||||
var matches = SiblingsOf(anchor, page).Where(candidate => kind switch
|
||||
{
|
||||
SameKind.Row => Near(candidate.Y, anchor.Y),
|
||||
SameKind.Column => Near(candidate.X, anchor.X),
|
||||
SameKind.Type => string.Equals(candidate.Type, anchor.Type, StringComparison.Ordinal),
|
||||
SameKind.Size => Near(candidate.Width, anchor.Width) && Near(candidate.Height, anchor.Height),
|
||||
_ => false,
|
||||
}).ToList();
|
||||
|
||||
// 잠금·숨김은 뺀다 — 편집하려고 넓히는 선택인데 편집할 수 없는 것이 섞이면
|
||||
// 정렬을 눌렀을 때 "왜 이건 안 움직이지"가 된다
|
||||
matches = matches.Where(c => !c.Model.Locked && !c.Model.Hidden).ToList();
|
||||
|
||||
var before = Selection.Items.Count;
|
||||
var merged = Selection.Items.Union(matches).Distinct().ToList();
|
||||
if (merged.Count == before)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
Selection.Set(merged, anchor);
|
||||
return merged.Count - before;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택 크기를 키우거나 줄인다 — <see cref="NudgeSelection"/> 의 빠져 있던 짝.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 위치는 방향키로 1px 미세 조정이 되는데 크기만 그 수단이 없었다.
|
||||
/// 입력칸 폭을 3px 늘리려면 오른쪽 인스펙터 W 칸으로 가서 숫자를 읽고 3 을 더해 입력한다 —
|
||||
/// 캔버스와 300px 떨어진 패널 사이를 마우스로 왕복한다. 마우스 핸들은 판정 반경이
|
||||
/// 화면 6px 이라 작은 컨트롤에서 3px 를 정확히 집기 어렵고, 스냅이 붙으면 원하는 값에 못 선다.
|
||||
///
|
||||
/// 넛지와 같은 400ms 코얼레스를 쓴다 — 연타가 Undo 스무 개로 쌓이면 안 된다.
|
||||
/// </summary>
|
||||
public void ResizeSelection(double dw, double dh)
|
||||
{
|
||||
if (Selection.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.SnapshotForNudge();
|
||||
foreach (var vm in Selection.Items)
|
||||
{
|
||||
if (vm.Model.Locked)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
vm.Width = Math.Max(1, Math.Round(vm.Width + dw));
|
||||
vm.Height = Math.Max(1, Math.Round(vm.Height + dh));
|
||||
// 선은 두께 축을 끌어서 바꿀 수 없다 — 레거시 MLine 이 매번 BorderWidth 로 되돌린다.
|
||||
// 허용하면 디자이너에만 두꺼운 선이 보이고 EMR 은 원래 두께로 그린다.
|
||||
if (SheetMe.Core.Catalog.LineGeometry.NormalizeThickness(vm.Model))
|
||||
{
|
||||
vm.NotifyAllChanged();
|
||||
}
|
||||
}
|
||||
Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택을 잠그거나 푼다 — 하나라도 안 잠겼으면 전부 잠근다(레이어 패널의 묶음 토글과 같은 규칙).
|
||||
///
|
||||
/// <b>왜 캔버스에서 되어야 하나.</b> 배경 괘선 40개를 잠가 두고 그 위에서 라벨만 만지고 싶을 때,
|
||||
/// 지금은 좌측 패널을 레이어 탭으로 바꾸고 트리에서 40행을 찾아 13px 자물쇠를 40번 눌러야 한다.
|
||||
/// 캔버스에서 마퀴로 40개를 잡아 놔도 한 번에 잠글 수단이 없었다.
|
||||
///
|
||||
/// <b>잠금 해제는 캔버스에서 못 한다.</b> 히트 판정이 잠긴 것을 후보에서 빼므로 우클릭조차 닿지 않는다 —
|
||||
/// 해제 경로는 레이어 패널에 있다. 그래서 캔버스 메뉴 이름은 '선택 잠그기'로 한정한다.
|
||||
/// <b>잠금·숨김은 저장되지 않는 편집 전용 상태다</b> — 영속처럼 읽히는 문구를 쓰지 않는다.
|
||||
/// </summary>
|
||||
public void ToggleLockSelection()
|
||||
{
|
||||
if (Selection.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.Snapshot();
|
||||
var locking = Selection.Items.Any(vm => !vm.Model.Locked);
|
||||
foreach (var vm in Selection.Items)
|
||||
{
|
||||
vm.Model.Locked = locking;
|
||||
vm.NotifyAllChanged();
|
||||
}
|
||||
RebuildLayerRows();
|
||||
RefreshOverlay();
|
||||
}
|
||||
|
||||
/// <summary>선택을 숨기거나 보인다 — 하나라도 보이면 전부 숨긴다</summary>
|
||||
public void ToggleHiddenSelection()
|
||||
{
|
||||
if (Selection.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.Snapshot();
|
||||
var hiding = Selection.Items.Any(vm => !vm.Model.Hidden);
|
||||
foreach (var vm in Selection.Items)
|
||||
{
|
||||
vm.Model.Hidden = hiding;
|
||||
vm.NotifyAllChanged();
|
||||
}
|
||||
RebuildLayerRows();
|
||||
RefreshOverlay();
|
||||
}
|
||||
|
||||
/// <summary>활성 페이지 전체 선택</summary>
|
||||
public void SelectAllOnActivePage()
|
||||
{
|
||||
|
||||
@@ -55,6 +55,22 @@ public sealed class SpacingMarkInfo
|
||||
public double TextY { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>잠긴 컨트롤 하나의 자리 — 월드 좌표. 오버레이가 점선 테두리로 그린다.</summary>
|
||||
public sealed class LockMarkInfo
|
||||
{
|
||||
/// <summary>좌상단 X(월드)</summary>
|
||||
public double X { get; init; }
|
||||
|
||||
/// <summary>좌상단 Y(월드)</summary>
|
||||
public double Y { get; init; }
|
||||
|
||||
/// <summary>너비(월드)</summary>
|
||||
public double W { get; init; }
|
||||
|
||||
/// <summary>높이(월드)</summary>
|
||||
public double H { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
|
||||
public sealed class TabBadgeInfo
|
||||
{
|
||||
@@ -172,6 +188,17 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
||||
|
||||
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
|
||||
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 잠긴 컨트롤의 테두리 — <b>편집 크롬</b>이라 종이에는 그려지지 않는다.
|
||||
///
|
||||
/// 이 층에 두는 이유가 있다. 종이 층(PageView)에 표시를 넣으면 <c>--db-render</c> 게이트가
|
||||
/// 그것을 찍어 md5 가 깨진다 — 그 게이트는 레거시와 픽셀이 같다는 것을 잠그고 있다.
|
||||
///
|
||||
/// 표시가 필요한 이유: 숨김은 0.25 투명이라 티가 나는데 <b>잠금은 아무 표시가 없었다</b>.
|
||||
/// 클릭도 드래그도 안 되는데 이유가 화면에 없으니 "왜 이것만 안 잡히지"만 남는다.
|
||||
/// </summary>
|
||||
public ObservableCollection<LockMarkInfo> LockMarks { get; } = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
|
||||
@@ -26,6 +26,20 @@
|
||||
-->
|
||||
<UserControl.ContextMenu>
|
||||
<ContextMenu>
|
||||
<!--
|
||||
선택 확장이 <b>맨 위</b>다. 아래 정렬·간격 명령이 전제하는 '올바른 선택'을
|
||||
만드는 수단이고, 그것 없이는 매번 "마퀴로 끌고 딸려온 것 빼기"가 앞에 붙는다.
|
||||
실측(787건): 행 무리의 94.2%에 침입자가 있고 그 수가 중앙값 7개다.
|
||||
-->
|
||||
<MenuItem Header="같은 행 선택" InputGestureText="Alt+1"
|
||||
Command="{Binding SelectSameCommand}" CommandParameter="Row"/>
|
||||
<MenuItem Header="같은 열 선택" InputGestureText="Alt+2"
|
||||
Command="{Binding SelectSameCommand}" CommandParameter="Column"/>
|
||||
<MenuItem Header="같은 종류 선택" InputGestureText="Alt+3"
|
||||
Command="{Binding SelectSameCommand}" CommandParameter="Type"/>
|
||||
<MenuItem Header="같은 크기 선택" InputGestureText="Alt+4"
|
||||
Command="{Binding SelectSameCommand}" CommandParameter="Size"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="왼쪽 맞춤" InputGestureText="Alt+A"
|
||||
Command="{Binding AlignCommand}" CommandParameter="Left"/>
|
||||
<MenuItem Header="오른쪽 맞춤" InputGestureText="Alt+D"
|
||||
@@ -61,6 +75,13 @@
|
||||
<MenuItem Header="맨 앞으로" Command="{Binding BringToFrontCommand}"/>
|
||||
<MenuItem Header="맨 뒤로" Command="{Binding SendToBackCommand}"/>
|
||||
<Separator/>
|
||||
<!--
|
||||
'잠그기'로 이름을 한정한다 — 해제는 여기서 못 한다. 히트 판정이 잠긴 것을
|
||||
후보에서 빼므로 잠긴 컨트롤에는 우클릭조차 닿지 않는다. 해제는 레이어 패널에서.
|
||||
-->
|
||||
<MenuItem Header="선택 잠그기" InputGestureText="Ctrl+L" Command="{Binding LockCommand}"/>
|
||||
<MenuItem Header="선택 숨기기" InputGestureText="Ctrl+Shift+H" Command="{Binding HideCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="복제" InputGestureText="Ctrl+D" Command="{Binding DuplicateCommand}"/>
|
||||
<MenuItem Header="삭제" InputGestureText="Del" Command="{Binding DeleteCommand}"/>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -29,6 +29,25 @@
|
||||
<KeyBinding Key="S" Modifiers="Control" Command="{Binding SaveFileCommand}"/>
|
||||
<KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/>
|
||||
<KeyBinding Key="W" Modifiers="Control" Command="{Binding CloseDocumentCommand}"/>
|
||||
<!--
|
||||
줌 — 커맨드 넷은 처음부터 있었고 <b>키만 없었다</b>. 8pt 글자를 확인하려 200% 로 올렸다가
|
||||
전체 배치를 보려면 캔버스에서 손을 떼고 화면 맨 아래 가운데 플로팅 바의 배율 버튼을 눌러
|
||||
팝업을 열고 '화면 맞춤'을 골라야 했다(2단계 + 마우스 왕복). 확대·축소조차 Ctrl+휠뿐이라
|
||||
트랙패드에서는 배율을 바꿀 키보드 수단이 아예 없었다.
|
||||
|
||||
반드시 창 InputBindings 여야 한다 — 캔버스 키 처리기는 포커스가 TextBox/ComboBox 면
|
||||
통째로 비켜서므로, 인스펙터 값을 고친 직후에는 줌 키가 죽는다.
|
||||
-->
|
||||
<KeyBinding Key="D0" Modifiers="Control" Command="{Binding ZoomResetCommand}"/>
|
||||
<KeyBinding Key="NumPad0" Modifiers="Control" Command="{Binding ZoomResetCommand}"/>
|
||||
<KeyBinding Key="D1" Modifiers="Control" Command="{Binding ZoomFitCommand}"/>
|
||||
<KeyBinding Key="NumPad1" Modifiers="Control" Command="{Binding ZoomFitCommand}"/>
|
||||
<KeyBinding Key="OemPlus" Modifiers="Control" Command="{Binding ZoomInCommand}"/>
|
||||
<KeyBinding Key="Add" Modifiers="Control" Command="{Binding ZoomInCommand}"/>
|
||||
<KeyBinding Key="OemMinus" Modifiers="Control" Command="{Binding ZoomOutCommand}"/>
|
||||
<KeyBinding Key="Subtract" Modifiers="Control" Command="{Binding ZoomOutCommand}"/>
|
||||
<!-- 좌우 패널 접기(Ctrl+\)는 코드비하인드에서 다룬다 — 열 너비를 직접 만지는 일이라
|
||||
뷰모델이 알 필요가 없다(F4 와 같은 규범) -->
|
||||
<!-- F4 로 인스펙터 첫 칸으로 들어간다. 전에는 인스펙터로 포커스를 보내는 키가 저장소에
|
||||
하나도 없어서, 캔버스에서 값을 고치려면 Tab 으로 좌측 패널·문서 탭·플로팅 바를 다 지나야 했다. -->
|
||||
<!-- F4 는 코드비하인드에서 다룬다 — 커맨드로 하면 뷰모델이 뷰의 포커스를 알아야 한다 -->
|
||||
|
||||
@@ -232,10 +232,24 @@ public partial class MainView : Window
|
||||
{
|
||||
if (e.Key == System.Windows.Input.Key.F4)
|
||||
{
|
||||
// 인스펙터가 접혀 있으면 먼저 편다 — 안 그러면 보이지도 않는 칸으로 포커스가 들어간다
|
||||
if (panelsCollapsed)
|
||||
{
|
||||
TogglePanels();
|
||||
}
|
||||
Inspector.FocusFirstRow();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
// Ctrl+\ — 좌우 패널을 함께 접었다 편다('종이만 보기').
|
||||
// 개별 토글 두 개를 두지 않는다: 목적이 하나라 키도 하나여야 손이 기억한다.
|
||||
if (e.Key is System.Windows.Input.Key.OemBackslash or System.Windows.Input.Key.Oem5
|
||||
&& System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control)
|
||||
{
|
||||
TogglePanels();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
// Ctrl+O — 서식 목록으로. 전에는 OS 파일 대화상자로 갔다.
|
||||
// 손이 먼저 가는 키인데 디스크로 가고, 운영 서식 1,271건은 전부 DB 에 있다.
|
||||
if (e.Key == System.Windows.Input.Key.O
|
||||
@@ -246,6 +260,47 @@ public partial class MainView : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>지금 좌우 패널이 접혀 있는가 — 접힌 폭을 '사용자가 정한 폭'으로 오해하지 않기 위한 상태</summary>
|
||||
private bool panelsCollapsed;
|
||||
|
||||
/// <summary>펼쳤을 때의 폭 — 접는 순간 기억해 두었다가 그대로 되돌린다</summary>
|
||||
private GridLength expandedLeft;
|
||||
private GridLength expandedRight;
|
||||
private double expandedLeftMin;
|
||||
private double expandedRightMin;
|
||||
|
||||
/// <summary>
|
||||
/// 좌우 패널을 함께 접었다 편다 — <b>종이만 보기</b>.
|
||||
///
|
||||
/// 좌 248 + 스플리터 5 + 스플리터 5 + 우 300 = 558px 가 <b>항상</b> 점유된다.
|
||||
/// MinWidth 180/250 때문에 스플리터를 끝까지 밀어도 사라지지 않고, 보기 메뉴에도 항목이 없었다.
|
||||
/// 가로 용지를 120% 로만 올려도 캔버스에 가로 스크롤이 생기는데 '종이만 보기' 상태가 없었다.
|
||||
///
|
||||
/// <b>MinWidth 도 함께 0 으로 내려야</b> 실제로 사라진다 — Width=0 만으로는 최소폭이 이긴다.
|
||||
/// 되돌릴 때 쓰려고 펼친 폭을 따로 기억한다(접힌 0 을 사용자가 정한 폭으로 착각하면 안 된다).
|
||||
/// </summary>
|
||||
private void TogglePanels()
|
||||
{
|
||||
if (panelsCollapsed)
|
||||
{
|
||||
LeftColumn.MinWidth = expandedLeftMin;
|
||||
RightColumn.MinWidth = expandedRightMin;
|
||||
LeftColumn.Width = expandedLeft;
|
||||
RightColumn.Width = expandedRight;
|
||||
panelsCollapsed = false;
|
||||
return;
|
||||
}
|
||||
expandedLeft = LeftColumn.Width;
|
||||
expandedRight = RightColumn.Width;
|
||||
expandedLeftMin = LeftColumn.MinWidth;
|
||||
expandedRightMin = RightColumn.MinWidth;
|
||||
LeftColumn.MinWidth = 0;
|
||||
RightColumn.MinWidth = 0;
|
||||
LeftColumn.Width = new GridLength(0);
|
||||
RightColumn.Width = new GridLength(0);
|
||||
panelsCollapsed = true;
|
||||
}
|
||||
|
||||
/// <summary>파일 메뉴·빈 화면 버튼에서 서식 목록으로 보낸다</summary>
|
||||
private void OnOpenFromListClick(object sender, RoutedEventArgs e) => FocusSheetList();
|
||||
|
||||
|
||||
@@ -39,6 +39,33 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
잠긴 컨트롤 표시 — 편집 크롬이므로 종이(PageView)에는 없다.
|
||||
그쪽에 넣으면 db-render md5 가 깨진다(레거시와의 픽셀 동일성을 잠그는 게이트다).
|
||||
숨김은 0.25 투명으로 티가 나는데 잠금은 아무 표시가 없어,
|
||||
안 잡히는 이유를 화면에서 알 방법이 없었다.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding LockMarks}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Rectangle Width="{Binding W}" Height="{Binding H}"
|
||||
Stroke="#8C8C8C" StrokeThickness="1" StrokeDashArray="2 2"
|
||||
Fill="Transparent" Opacity="0.7"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 마퀴(러버밴드) -->
|
||||
<Rectangle Canvas.Left="{Binding MarX}" Canvas.Top="{Binding MarY}"
|
||||
Width="{Binding MarW}" Height="{Binding MarH}"
|
||||
|
||||
Reference in New Issue
Block a user