Files
SheetMe/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
T
MsystechandClaude Fable 5 fccd441193 사용성 1순위 — 조용히 틀리게 동작하던 넷을 고친다
사용성 감사를 돌렸더니 결론이 뜻밖이었다. <b>없는 기능이 문제가 아니었다</b> —
정렬 6종·간격 4종·같은 크기·그룹·스냅 가이드·레이어 트리·일괄 편집이 이미 다 있다.
진짜 문제는 있는 것이 틀리게 동작하고, 그 실패가 화면에 아무 흔적을 남기지 않는 것이었다.
넷 다 사용자가 무엇을 잃었는지 모르는 채로 저장하는 경로다.

## ① Ctrl+Shift+Z 가 다시 실행이 아니라 되돌리기였다

CanvasKeyboardBehavior 의 `case Key.Z when ctrl` 에 shift 검사가 없었다.
바로 아래 Key.G 는 ctrl && shift 를 가르는데 Z 만 빠져 있었다.
두 번 되돌린 뒤 한 단계 물리려 Ctrl+Shift+Z 를 누르면 <b>세 단계 전</b>으로 간다.
방향이 반대인 줄 모르면 두세 번 더 눌러 편집분을 통째로 날리는데,
되돌리기는 언제나 성공하므로 경고가 없다. 레이어 패널 쪽도 같은 짝이라 함께 고쳤다.

## ② Ctrl+드래그 복사를 Esc 로 취소하면 사본이 남았다

이번 세션에 Ctrl+드래그 복사를 넣으면서 만든 회귀다. CancelDrag 는 좌표만 되돌리는데,
사본은 이미 문서에 들어가 있으므로 원래 자리로 되돌리면 <b>원본과 픽셀 단위로 겹친다</b>.
화면은 취소된 것처럼 보이고, 그대로 저장하면 종이에 같은 글자가 두 번 인쇄된다 —
증거는 "조금 굵어 보인다" 뿐이다.

복제한 드래그였으면 좌표 복원 대신 복제 자체를 물린다. 순서가 중요하다:
Undo 는 문서를 딥클론으로 갈아끼우므로 origBounds·downControl 이 죽은 참조가 된다 —
상태를 먼저 비우고 그다음에 되돌린다. 그리고 DiscardRedo 로 redo 이력도 버린다.
Esc 는 "되돌린다"가 아니라 "없던 일로 한다"이므로, Ctrl+Y 로 되살아나면 취소가 아니다.

## ③ 우클릭이 커서 아래를 잡지 않았다

우클릭 메뉴는 20항목인데 우클릭 <b>선택</b> 핸들러가 저장소 전역에 0건이었다.
라벨 A 를 골라 둔 채 떨어진 체크박스 B 를 우클릭하면 메뉴는 B 위에 뜨는데
[삭제]는 A 를 지운다. 선택이 비어 있으면 항목이 전부 살아 있는 채로 아무 일도 안 한다.
윈도우의 거의 모든 프로그램이 지키는 규약이라 사용자는 메뉴가 가리키는 대상을
확인할 생각조차 하지 않는다 — 메뉴 전체의 신뢰가 이 한 걸음에 달려 있다.

이미 선택 안에 있으면 선택을 유지한다(다중 선택 우클릭 정렬 흐름을 깨면 안 된다).
e.Handled 는 두지 않아 기존 ContextMenu 가 그대로 열린다.

<b>함정 하나</b>: HitTestControl 은 잠금·숨김을 히트 후보에서 아예 빼므로,
잠가 둔 배경 괘선 위 우클릭이 '빈 곳'으로 판정돼 선택이 사라진다.
잠금은 편집 보호지 부재가 아니다 — AnyControlAt 을 따로 만들어 그 위에서는 선택을 지우지 않는다.

## ④ 저장 안 된 문서를 화면에서 구별할 수 없었다

UndoService.IsDirty 는 저장 지점 깊이까지 추적해 정교하게 있었는데
(되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다.
서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤, 어느 것이 안 저장됐는지 알 방법이 없다 —
탭 제목이 저장 전후로 글자 하나 다르지 않다. 그래서 안전하게 전부 다시 저장하게 되는데
<b>DB 저장은 새 버전을 만든다</b> — 서식마다 쓸데없는 버전이 쌓인다.

탭에 6px 점을 찍는다. 통지는 값이 <b>바뀔 때만</b> 울린다 — 매번 울리면 드래그 한 번에 수백 번이다.

## 판정

- 단위 시험 388
- edit-smoke 362건 전건 통과 (Esc 취소 4건 · 되돌리기/저장표시 6건 · 잠김 히트 4건 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --dialog-shots · --cleartype 전건 통과

## 감사에서 나온 나머지

2순위(선택 확장 4종·줌 단축키·Ctrl+방향키 크기·잠금 토글)와 3순위는 아직 안 했다.
<b>레이아웃은 바꾸지 않기로 했다</b> — 좌 248/우 300 은 이미 측정된 값이고
(레이어 행 최소치, 인스펙터 X|Y 한 줄 최소치) 라벨 1개 선택 시 자주 쓰는 4종이
0 스크롤·0 클릭이다. 다만 패널을 접을 수단이 없는 것은 실재하는 부재다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:16:15 +09:00

2827 lines
112 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.ObjectModel;
using System.Windows;
using M.Framework.WPF;
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels.Controls;
using SheetMe.Designer.ViewModels.Inspector;
namespace SheetMe.Designer.ViewModels;
/// <summary>폰트 사용 집계 1조합 — (타입, 글꼴, 크기, 스타일)별 컨트롤 목록</summary>
public sealed class FontUsageGroup
{
/// <summary>중립 타입명</summary>
public string Type { get; }
/// <summary>글꼴 패밀리</summary>
public string Family { get; }
/// <summary>크기(pt)</summary>
public double SizePt { get; }
/// <summary>굵게</summary>
public bool Bold { get; }
/// <summary>기울임</summary>
public bool Italic { get; }
/// <summary>밑줄</summary>
public bool Underline { get; }
/// <summary>Font 미지정(부모 상속) 그룹 여부</summary>
public bool IsInherited { get; }
/// <summary>해당 조합의 컨트롤들</summary>
public List<ControlViewModel> Controls { get; } = new();
/// <summary>목록 표시 텍스트</summary>
public string Display =>
$"{Type} · {Family} {SizePt:0.##}pt" +
(Bold ? " 굵게" : "") + (Italic ? " 기울임" : "") + (Underline ? " 밑줄" : "") +
(IsInherited ? " (상속)" : "") + $" — {Controls.Count}개";
public FontUsageGroup(string type, string family, double sizePt, bool bold, bool italic, bool underline, bool isInherited)
{
Type = type;
Family = family;
SizePt = sizePt;
Bold = bold;
Italic = italic;
Underline = underline;
IsInherited = isInherited;
}
}
/// <summary>
/// 서식 문서 1개의 편집 허브 — 페이지 VM 트리·선택·스냅·Undo·상호작용·오버레이를 소유.
/// 모델(FormDocument)이 진실이고 VM 은 투영: Undo 복원 = 문서 교체 + RebuildFromDocument.
/// </summary>
public sealed class DesignerViewModel : ViewModelBase
{
#region Member Fields
private const double PageGap = 28;
private const double WorldPad = 24;
private static readonly List<ControlElement> clipboard = new();
/// <summary>한 계단에 몇 번 내려간 뒤 옆으로 비키는가 — 종이를 벗어나기 전에 접는다</summary>
private const int PlacementLaneSteps = 12;
/// <summary>팔레트 즉시 배치 계단 — 지우고 다시 놓아도 같은 자리로 돌아가지 않게 단조 증가한다</summary>
private int placementStep;
private double zoom = 1.0;
private FormDocument document;
private PageViewModel? selectedPage;
private string layerFilter = string.Empty;
private double viewportWidth;
private double viewportHeight;
private bool isTabOrderMode;
private readonly List<ControlViewModel> tabOrderSequence = new();
private readonly Dictionary<string, SpreadGridInfo>? spreadDesigns;
/// <summary>정렬을 페이지에 맞출 것인가 — 인스펙터 토글이 세운다</summary>
private bool alignToPage;
// 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님)
private readonly HashSet<string> collapsedGroups = new(StringComparer.Ordinal);
private readonly HashSet<PageViewModel> collapsedPages = new();
private PageViewModel? inspectedPage;
private readonly HashSet<string> layerTypeFilter = new(StringComparer.Ordinal);
private readonly List<System.Collections.Specialized.INotifyCollectionChanged> watchedControls = new();
#endregion
#region Properties
/// <summary>편집 중인 문서(진실 원본)</summary>
public FormDocument Document => document;
/// <summary>파일 출처 경로(.xml/.json) — DB/신규 문서는 null</summary>
public string? FilePath { get; set; }
/// <summary>DB 출처 여부</summary>
public bool IsFromDb { get; set; }
/// <summary>이력 버전 열람 탭 여부 — 열람한 과거 SdgKey (활성본이면 null)</summary>
public decimal? HistorySdgKey { get; set; }
/// <summary>탭 헤더 표시명 — [코드] 제목 (출처)</summary>
public string DisplayName
{
get
{
var title = document.Title.Length > 0 ? document.Title : document.FormId;
if (HistorySdgKey is not null)
{
return $"{document.FormId} {title} (이력 {HistorySdgKey})";
}
if (IsFromDb)
{
return $"{document.FormId} {title}";
}
if (FilePath is not null)
{
return System.IO.Path.GetFileName(FilePath);
}
return title.Length > 0 ? title : "새 서식";
}
}
/// <summary>탭 헤더 갱신 통지(저장/개명 후)</summary>
public void NotifyDisplayNameChanged() => OnPropertyChanged(nameof(DisplayName));
/// <summary>
/// 저장하지 않은 변경이 있는가 — <b>탭에 점으로 보인다</b>.
///
/// 이 상태는 <see cref="UndoService.IsDirty"/> 가 저장 지점 깊이까지 추적해 정교하게 들고
/// 있었는데(되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다.
/// 서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤에는, 어느 것이 안 저장됐는지
/// <b>확인할 방법이 없다</b> — 탭 제목이 저장 전후로 글자 하나 다르지 않다.
/// 그래서 안전하게 전부 다시 저장하게 되는데, DB 저장은 <b>새 버전을 만든다</b> —
/// 서식마다 쓸데없는 버전이 쌓인다.
/// </summary>
public bool IsDirty => Undo.IsDirty;
/// <summary>더티 표시 갱신 통지 — 편집·저장·되돌리기 뒤에 부른다</summary>
public void NotifyDirtyChanged() => OnPropertyChanged(nameof(IsDirty));
/// <summary>페이지 VM 목록</summary>
public ObservableCollection<PageViewModel> Pages { get; } = new();
/// <summary>선택 서비스</summary>
public SelectionService Selection { get; }
/// <summary>스냅 엔진</summary>
public SnapEngine Snap { get; }
/// <summary>Undo/Redo 서비스</summary>
public UndoService Undo { get; }
/// <summary>마우스 상호작용 상태머신</summary>
public InteractionController Interaction { get; }
/// <summary>선택/마퀴/가이드 오버레이 상태</summary>
public SelectionOverlayViewModel Overlay { get; } = new();
/// <summary>속성 인스펙터</summary>
public InspectorViewModel Inspector { get; }
/// <summary>페이지 패널에서 선택된 페이지</summary>
public PageViewModel? SelectedPage
{
get => selectedPage;
set
{
if (SetProperty(ref selectedPage, value))
{
if (value is not null)
{
Selection.ActivePage = value;
}
// 아웃라인이 전 페이지를 담으므로 목록 자체는 그대로다 — 활성 표시만 갱신한다
foreach (var header in PageRows)
{
header.NotifyStateChanged();
}
}
}
}
/// <summary>
/// 문서 전 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다.
/// (예전에는 <c>page.Controls</c> 를 그대로 ItemsSource 로 줘서 공짜로 따라왔지만,
/// 아웃라인이 평탄화 결과가 된 뒤로는 원본이 변할 때 다시 만들어야 한다.)
/// </summary>
private void WatchLayerSource()
{
foreach (var watched in watchedControls)
{
watched.CollectionChanged -= OnLayerSourceChanged;
}
watchedControls.Clear();
foreach (var page in Pages)
{
page.Controls.CollectionChanged += OnLayerSourceChanged;
watchedControls.Add(page.Controls);
}
}
private void OnLayerSourceChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
=> RebuildLayerRows();
/// <summary>
/// 레이어 검색어 — 이름(LayerName)·Id 부분일치. 서식당 컨트롤이 수백 개인 경우가 흔해
/// (운영 최대 1,067개) 스크롤만으로는 대상을 찾을 수 없다.
/// </summary>
public string LayerFilter
{
get => layerFilter;
set
{
if (SetProperty(ref layerFilter, value ?? string.Empty))
{
RebuildLayerRows();
}
}
}
/// <summary>
/// 레이어 목록에 실제로 표시할 행 — 그룹 폴더 행(<see cref="LayerGroupViewModel"/>)과
/// 컨트롤 행(<see cref="ControlViewModel"/>)이 섞인 <b>평탄화 결과</b>다.
///
/// TreeView 를 쓰지 않는 이유: 목록이 최대 1,067행이라 가상화가 필요하고, 컨트롤 행 템플릿
/// (눈·자물쇠·2줄 이름)을 이미 ListBox 기준으로 만들어 뒀다. 계층은 <c>LayerIndent</c> 여백으로만 표현한다
/// (인스펙터 섹션이 쓰는 방식과 같다).
/// </summary>
public ObservableCollection<object> LayerRows { get; } = new();
/// <summary>페이지 구획 목록 — 트리와 별도. 페이지 조작(추가·복제·삭제·이동)은 전부 이쪽이다</summary>
public ObservableCollection<LayerPageViewModel> PageRows { get; } = new();
/// <summary>트리에 페이지 마디를 둘 것인가 — 한 장짜리 문서에서는 뜻이 없다</summary>
public bool ShowPageNodes => Pages.Count > 1;
/// <summary>
/// 컨트롤 행이 하나도 없는지 — 빈 목록에 안내를 띄우기 위한 플래그.
/// 페이지 헤더와 '페이지 추가' 행은 늘 있으므로 행 수가 아니라 컨트롤 행 유무로 판단한다.
/// </summary>
public bool IsLayerListEmpty => !LayerRows.OfType<Controls.ControlViewModel>().Any();
/// <summary>레이어 목록이 비어 보이는 이유 — 걸러진 것인지, 애초에 없는 것인지</summary>
public string LayerEmptyText =>
Pages.All(p => p.Controls.Count == 0)
? "아직 컨트롤이 없습니다"
: "검색·필터에 맞는 항목이 없습니다";
/// <summary>타입 필터가 걸려 있는지 — 헤더 버튼 강조/초기화 버튼 활성 판단</summary>
public bool HasLayerTypeFilter => layerTypeFilter.Count > 0;
/// <summary>타입 필터 상태 툴팁</summary>
public string LayerTypeFilterTip =>
layerTypeFilter.Count == 0 ? "타입으로 거르기" : $"{layerTypeFilter.Count}종만 표시 중";
/// <summary>줌 배율(0.25~3.0)</summary>
public double Zoom
{
get => zoom;
set
{
var clamped = Math.Clamp(value, 0.25, 3.0);
if (SetProperty(ref zoom, clamped))
{
OnPropertyChanged(nameof(ZoomPercentText));
// 오버레이 선 두께를 화면 1px 로 유지하려면 오버레이가 배율을 알아야 한다
Overlay.Zoom = clamped;
}
}
}
/// <summary>줌 표시 텍스트</summary>
public string ZoomPercentText => $"{zoom * 100:0}%";
/// <summary>월드 논리 너비</summary>
public double WorldWidth => Pages.Count == 0 ? 720 : Pages.Max(p => p.WidthDip);
/// <summary>월드 논리 높이</summary>
public double WorldHeight => Pages.Count == 0
? 856
: Pages[^1].OffsetY + Pages[^1].HeightDip + WorldPad;
#endregion
#region Constructors
public DesignerViewModel(FormDocument document)
: this(document, null)
{
}
public DesignerViewModel(FormDocument document, Dictionary<string, SpreadGridInfo>? spreadDesigns)
{
this.spreadDesigns = spreadDesigns;
this.document = document;
Selection = new SelectionService();
// appsettings 의 Designer 섹션을 실제로 반영한다(그동안 읽히지 않는 죽은 설정이었다)
Snap = new SnapEngine
{
GridSize = ConfigService.Current.GridSize,
ScreenTolerance = ConfigService.Current.SnapThreshold,
};
Undo = new UndoService(() => Document, ReplaceDocument);
// 저장 안 됨 표시가 탭에 따라오게 한다 — 값이 바뀔 때만 울린다
Undo.DirtyChanged += NotifyDirtyChanged;
Interaction = new InteractionController(this);
Inspector = new InspectorViewModel(this);
Selection.Changed += RefreshOverlay;
// 순서 주의 — 인스펙터가 Rebuild 하기 전에 페이지 모드를 걷어야 한다
// (Inspector 는 생성자 안에서 SetChanged 를 구독하므로 여기가 먼저다)
Selection.SetChanged += ClearInspectedPageOnSelection;
Selection.SetChanged += LayerSelectionChanged;
UndoCommand = new Command((sender, e) => Undo.Undo());
RedoCommand = new Command((sender, e) => Undo.Redo());
DeleteCommand = new Command((sender, e) => DeleteSelection());
CopyCommand = new Command((sender, e) => CopySelection());
PasteCommand = new Command((sender, e) => Paste());
DuplicateCommand = new Command((sender, e) => { CopySelection(); Paste(); });
SelectAllCommand = new Command((sender, e) => SelectAllOnActivePage());
BringToFrontCommand = new Command((sender, e) => ReorderSelection(toFront: true));
SendToBackCommand = new Command((sender, e) => ReorderSelection(toFront: false));
AlignCommand = new Command((object param) => Align(param as string ?? string.Empty));
AddPageCommand = new Command((sender, e) => AddPage());
RemovePageCommand = new Command((sender, e) => RemoveSelectedPage());
GroupCommand = new Command((sender, e) => GroupSelection());
UngroupCommand = new Command((sender, e) => UngroupSelection());
CutCommand = new Command((sender, e) => CutSelection());
SizeToControlCommand = new Command((object param) => SizeToControl(param as string ?? "Both"));
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());
RebuildFromDocument();
}
#endregion
#region Commands
/// <summary>실행 취소</summary>
public ICustomCommand? UndoCommand { get; set; }
/// <summary>다시 실행</summary>
public ICustomCommand? RedoCommand { get; set; }
/// <summary>선택 삭제</summary>
public ICustomCommand? DeleteCommand { get; set; }
/// <summary>복사</summary>
public ICustomCommand? CopyCommand { get; set; }
/// <summary>붙여넣기</summary>
public ICustomCommand? PasteCommand { get; set; }
/// <summary>복제(Ctrl+D)</summary>
public ICustomCommand? DuplicateCommand { get; set; }
/// <summary>활성 페이지 전체 선택</summary>
public ICustomCommand? SelectAllCommand { get; set; }
/// <summary>맨 앞으로</summary>
public ICustomCommand? BringToFrontCommand { get; set; }
/// <summary>맨 뒤로</summary>
public ICustomCommand? SendToBackCommand { get; set; }
/// <summary>정렬(파라미터: Left/Right/Top/Bottom/CenterH/CenterV)</summary>
public ICustomCommand? AlignCommand { get; set; }
/// <summary>페이지 추가</summary>
public ICustomCommand? AddPageCommand { get; set; }
/// <summary>선택 페이지 삭제</summary>
public ICustomCommand? RemovePageCommand { get; set; }
/// <summary>그룹(Ctrl+G) — 선택을 한 단위로 묶음(편집 전용, 저장 안 함)</summary>
public ICustomCommand? GroupCommand { get; set; }
/// <summary>그룹 해제(Ctrl+Shift+G)</summary>
public ICustomCommand? UngroupCommand { get; set; }
/// <summary>잘라내기(Ctrl+X)</summary>
public ICustomCommand? CutCommand { get; set; }
/// <summary>같은 크기로(파라미터: Both/Width/Height — 기준 컨트롤에 맞춤)</summary>
public ICustomCommand? SizeToControlCommand { get; set; }
/// <summary>간격 조절(파라미터: HorizEqual/HorizIncrease/HorizDecrease/HorizConcat/VertEqual/...)</summary>
public ICustomCommand? SpacingCommand { get; set; }
/// <summary>페이지 가운데 맞춤(파라미터: H/V)</summary>
public ICustomCommand? CenterInPageCommand { get; set; }
/// <summary>탭순서 편집 모드 토글(시작/완료)</summary>
public ICustomCommand? TabOrderCommand { get; set; }
#endregion
#region Methods - 문서/
/// <summary>모델 → VM 트리 재구성 — 열기/Undo 복원 시 호출</summary>
public void RebuildFromDocument()
{
Selection.Clear();
Pages.Clear();
for (var i = 0; i < document.Pages.Count; i++)
{
Pages.Add(DocumentMapper.CreatePage(document.Pages[i], i));
}
RecalcPageOffsets();
Selection.ActivePage = Pages.FirstOrDefault();
SelectedPage = Pages.FirstOrDefault();
// Spread 격자 주입(E_SpdMst 파싱 결과 — 컨트롤명 매칭)
if (spreadDesigns is not null)
{
foreach (var spread in AllControls().OfType<SpreadViewModel>())
{
if (spreadDesigns.TryGetValue(spread.Id, out var grid))
{
spread.GridInfo = grid;
}
}
}
OnPropertyChanged(nameof(WorldWidth));
OnPropertyChanged(nameof(WorldHeight));
RefreshOverlay();
Inspector.Rebuild();
// 페이지 인스턴스가 통째로 새로 만들어졌다 — 구독과 접힘 상태를 갈아끼우고 목록을 다시 만든다
collapsedPages.Clear();
WatchLayerSource();
RebuildLayerRows();
}
/// <summary>
/// 페이지 추가 — 문서 끝에 빈 페이지(720×856).
///
/// 루트 이름은 페이지 수가 아니라 <b>사용 중인 이름</b>을 보고 정한다.
/// 페이지 수로 지으면 루트가 MDesignerHost1·MDesignerHost3 인 2장짜리 서식에서
/// 곧바로 MDesignerHost3 이 중복된다(레거시는 전 페이지를 훑어 최대번호+1 을 붙인다).
/// </summary>
public void AddPage()
{
Undo.Snapshot();
var page = LegacyXmlSerializer.CreateEmptyPage(document.Pages.Count + 1);
RenamePageRoot(page, IdGenerator.CollectUsed(document));
document.Pages.Add(page);
RebuildFromDocument();
SelectedPage = Pages.LastOrDefault();
}
/// <summary>
/// 페이지 루트에 유일한 이름을 부여한다 — 이름과 Name 속성을 함께 맞춘다.
///
/// 루트 이름이 겹치면 제자리 갱신 저장이 깨진다: E_SctMst 동기화는 SctObjNam 사전이라
/// 둘째 루트가 첫째 루트 행에 매핑되고, 그 페이지 자식들의 SctParObj 가 존재하지 않는
/// ObjID 를 가리킨 채 남는다(화면은 뜨지만 항목 계층이 깨진다).
/// </summary>
private static void RenamePageRoot(FormPage page, HashSet<string> usedIds)
{
page.Root.Id = IdGenerator.NextId(usedIds, LegacyTypeCatalog.HostClassName);
if (page.Root.Props.Contains("Name"))
{
page.Root.Props.SetText("Name", page.Root.Id);
}
}
/// <summary>
/// 페이지 복제 — 용지 설정과 컨트롤을 통째로 베껴 바로 뒤에 끼운다.
///
/// 컨트롤 이름(Id)은 <b>문서 안에서 유일해야 한다</b> — 액션 대상·입력 파라미터가 이름으로 서로를
/// 가리키고 CalcBox 수식도 이름으로 합산하므로, 같은 이름이 두 페이지에 있으면 런타임이 어느 쪽을
/// 잡을지 알 수 없다. 그래서 붙여넣기와 같은 경로로 전부 새 이름을 발급한다.
/// 그룹도 복제본끼리 다시 묶는다(원본 그룹에 끼면 한쪽을 클릭할 때 반대편까지 딸려 온다).
/// </summary>
public PageViewModel? DuplicatePage(PageViewModel? source)
{
var target = source ?? SelectedPage;
if (target is null)
{
return null;
}
Undo.Snapshot();
var clone = target.Model.Clone();
var usedIds = IdGenerator.CollectUsed(document);
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
// 페이지 루트도 이름을 새로 받아야 한다 — Clone 이 Root.Id 까지 복사하므로
// 자식만 재발급하면 한 서식에 같은 이름의 루트가 둘 생긴다
RenamePageRoot(clone, usedIds);
foreach (var element in clone.Controls)
{
AssignNewIds(element, usedIds);
RemapGroupId(element, groupRemap);
}
var index = document.Pages.IndexOf(target.Model);
document.Pages.Insert(index < 0 ? document.Pages.Count : index + 1, clone);
RebuildFromDocument();
var created = Pages.FirstOrDefault(p => ReferenceEquals(p.Model, clone));
if (created is not null)
{
SelectedPage = created;
}
return created;
}
/// <summary>페이지 삭제(확인 후) — 최소 1페이지 유지</summary>
public void RemovePage(PageViewModel? page)
{
var target = page ?? SelectedPage;
if (target is null || document.Pages.Count <= 1)
{
return;
}
if (!DialogService.Confirm("페이지 삭제", $"페이지 {target.Index + 1}을(를) 삭제할까요?",
$"컨트롤 {target.Controls.Count}개가 함께 삭제됩니다.", yes: "삭제", no: "취소", destructive: true))
{
return;
}
Undo.Snapshot();
document.Pages.Remove(target.Model);
RebuildFromDocument();
}
/// <summary>선택 페이지 삭제(확인 후) — 최소 1페이지 유지</summary>
public void RemoveSelectedPage()
{
if (SelectedPage is null || document.Pages.Count <= 1)
{
return;
}
if (!DialogService.Confirm("페이지 삭제", $"페이지 {SelectedPage.Index + 1}을(를) 삭제할까요?",
$"컨트롤 {SelectedPage.Controls.Count}개가 함께 삭제됩니다.", yes: "삭제", no: "취소", destructive: true))
{
return;
}
RemoveSelectedPageForSmoke();
}
/// <summary>선택 페이지 즉시 삭제(확인 없음 — 스모크/내부용)</summary>
public void RemoveSelectedPageForSmoke()
{
if (SelectedPage is null || document.Pages.Count <= 1)
{
return;
}
Undo.Snapshot();
document.Pages.Remove(SelectedPage.Model);
RebuildFromDocument();
}
/// <summary>선택 페이지 용지 크기 적용(px)</summary>
public void ApplyPaperSize(double width, double height)
{
if (SelectedPage is null || width < 100 || height < 100)
{
return;
}
Undo.Snapshot();
SelectedPage.Model.Width = Math.Round(width);
SelectedPage.Model.Height = Math.Round(height);
SelectedPage.Model.Root.HasSize = true;
SelectedPage.NotifySizeChanged();
RecalcPageOffsets();
OnPropertyChanged(nameof(WorldWidth));
OnPropertyChanged(nameof(WorldHeight));
}
/// <summary>
/// 용지 크기가 바뀐 뒤 — 페이지 스택 오프셋과 월드 크기를 다시 계산한다.
/// 페이지가 여러 장이면 한 장의 높이 변화가 아래 페이지 전부를 밀어낸다.
/// </summary>
public void NotifyPaperResized()
{
RecalcPageOffsets();
OnPropertyChanged(nameof(WorldWidth));
OnPropertyChanged(nameof(WorldHeight));
RefreshOverlay();
}
/// <summary>
/// 페이지 기본 글꼴이 바뀐 뒤 — 그 페이지 컨트롤의 상속 컨텍스트를 다시 흘린다.
/// Font 를 명시하지 않은 컨트롤은 루트 글꼴을 물려받으므로 값 하나가 화면 전체를 바꾼다.
/// </summary>
public void RefreshPageInheritance(PageViewModel page)
{
var rootFontText = page.Model.Root.Props.GetText("Font");
var rootFont = rootFontText is not null ? LegacyFormat.ParseFont(rootFontText) : new LegacyFont();
foreach (var control in page.Controls)
{
control.ResolveVisualContext(rootFont, control.ParentForeground);
}
RefreshOverlay();
}
/// <summary>
/// 문서 교체 후 재구성 — Undo 복원 경로.
///
/// <b>선택을 되살린다.</b> 재구성의 첫 줄이 <c>Selection.Clear()</c> 라, 전에는 되돌리기 한 번마다
/// 선택이 통째로 증발했다. 여러 개를 함께 고쳐 보는 작업의 흐름이
/// '여럿 고르기 → 바꾸기 → 눈으로 확인 → Ctrl+Z → 다른 값으로 다시' 인데
/// 첫 되돌리기에서 끊기고, 마퀴로 만든 열두 개 선택을 매번 다시 만들어야 했다.
///
/// 기준은 Id 다 — 문서 안에서 유일하고(<c>IdGenerator.CollectUsed</c>) 문서를 갈아끼워도 같다.
/// 못 찾은 것은 조용히 뺀다(삭제를 다시 실행한 경우처럼 정말 없어졌을 수 있다).
/// </summary>
public void ReplaceDocument(FormDocument newDocument)
{
var keepIds = Selection.Items.Select(vm => vm.Id).ToList();
var primaryId = Selection.Primary?.Id;
document = newDocument;
RebuildFromDocument();
if (keepIds.Count == 0)
{
return;
}
var wanted = new HashSet<string>(keepIds, StringComparer.OrdinalIgnoreCase);
var found = new List<ControlViewModel>();
ControlViewModel? primary = null;
void Visit(IEnumerable<ControlViewModel> controls)
{
foreach (var vm in controls)
{
if (wanted.Contains(vm.Id))
{
found.Add(vm);
if (primaryId is not null && string.Equals(vm.Id, primaryId, StringComparison.OrdinalIgnoreCase))
{
primary = vm;
}
}
if (vm is ContainerViewModel container)
{
Visit(container.Children);
}
}
}
foreach (var page in Pages)
{
Visit(page.Controls);
}
if (found.Count > 0)
{
Selection.Set(found, primary ?? found[0]);
}
}
/// <summary>페이지 세로 오프셋 재계산</summary>
public void RecalcPageOffsets()
{
double y = 0;
foreach (var page in Pages)
{
page.OffsetY = y;
y += page.HeightDip + PageGap;
}
}
/// <summary>줌 확대</summary>
public void ZoomIn() => Zoom = Math.Round(Zoom * 1.1, 2);
/// <summary>줌 축소</summary>
public void ZoomOut() => Zoom = Math.Round(Zoom / 1.1, 2);
/// <summary>줌 100%</summary>
public void ZoomReset() => Zoom = 1.0;
/// <summary>
/// 캔버스 뷰포트 크기 통지 — DesignerCanvasView 가 SizeChanged 에서 호출한다.
/// ZoomFit 은 뷰포트를 알아야 하는데 VM 은 시각 트리를 모르므로 값만 받아 둔다.
/// </summary>
public void ReportViewport(double width, double height)
{
viewportWidth = width;
viewportHeight = height;
}
/// <summary>
/// 화면 맞춤 — 활성 페이지 한 장이 뷰포트에 들어오는 배율.
/// 문서 전체(WorldHeight)가 아니라 페이지 한 장 기준이다. 7페이지 서식을 통째로 맞추면
/// 14% 가 되어 아무것도 안 보인다(운영 최대 7페이지).
/// </summary>
public void ZoomFit()
{
var page = SelectedPage ?? (Pages.Count > 0 ? Pages[0] : null);
if (page is null || viewportWidth <= 0 || viewportHeight <= 0)
{
return;
}
// Border Padding 24 양쪽 + 스크롤바 여유
var availableWidth = viewportWidth - 64;
var availableHeight = viewportHeight - 64;
if (availableWidth <= 0 || availableHeight <= 0)
{
return;
}
Zoom = Math.Round(Math.Min(availableWidth / page.WidthDip, availableHeight / page.HeightDip), 2);
}
#endregion
#region Methods - 좌표/히트테스트
/// <summary>최상위 조상(페이지 직속) — 자신이 최상위면 자신</summary>
private static ControlViewModel RootAncestorOf(ControlViewModel viewModel)
{
var current = viewModel;
while (current.Parent is not null)
{
current = current.Parent;
}
return current;
}
/// <summary>VM 이 속한 페이지(컨테이너 자식 포함)</summary>
public PageViewModel? PageOf(ControlViewModel viewModel)
{
var root = RootAncestorOf(viewModel);
return Pages.FirstOrDefault(p => p.Controls.Contains(root));
}
/// <summary>부모 체인의 월드 오프셋(페이지 OffsetY 포함) — 월드 = 오프셋 + (X,Y)</summary>
public Point ParentWorldOffset(ControlViewModel viewModel)
{
double x = 0;
double y = 0;
var parent = viewModel.Parent;
while (parent is not null)
{
x += parent.X;
y += parent.Y;
parent = parent.Parent;
}
y += PageOf(viewModel)?.OffsetY ?? 0;
return new Point(x, y);
}
/// <summary>월드 Y 로 페이지 판정 — 페이지 사이 간격은 가까운 쪽</summary>
public PageViewModel? PageAt(double worldY)
{
foreach (var page in Pages)
{
if (worldY <= page.OffsetY + page.HeightDip + PageGap / 2)
{
return page;
}
}
return Pages.LastOrDefault();
}
/// <summary>컨트롤의 월드 bbox(컨테이너 자식은 부모 체인 오프셋 반영)</summary>
public Rect WorldBoundsOf(ControlViewModel viewModel)
{
var offset = ParentWorldOffset(viewModel);
return new Rect(offset.X + viewModel.X, offset.Y + viewModel.Y,
Math.Max(1, viewModel.Width), Math.Max(1, viewModel.Height));
}
/// <summary>선택 전체의 월드 bbox — 선택 없음이면 null</summary>
public Rect? SelectionWorldBounds()
{
Rect? union = null;
foreach (var item in Selection.Items)
{
var rect = WorldBoundsOf(item);
union = union is null ? rect : Rect.Union(union.Value, rect);
}
return union;
}
/// <summary>월드 좌표 최상위 컨트롤 히트 — 컨테이너는 자식 우선(재귀), 잠금/숨김 제외</summary>
public ControlViewModel? HitTestControl(Point world)
{
var page = PageAt(world.Y);
if (page is null)
{
return null;
}
var local = new Point(world.X, world.Y - page.OffsetY);
return HitInList(page.Controls, local);
}
/// <summary>
/// 이 좌표에 <b>무엇이든</b> 있는가 — 잠긴 것·숨긴 것까지 포함해서.
///
/// <see cref="HitTestControl"/> 은 잠금·숨김을 후보에서 아예 빼므로 그 위를 "빈 곳"이라고 답한다.
/// 선택을 푸는 판단에는 그 답을 쓰면 안 된다 — 잠가 둔 배경 괘선 위에서 우클릭할 때마다
/// 선택이 사라져 "왜 자꾸 풀리지"가 된다. 잠금은 <b>편집 보호</b>지 부재가 아니다.
/// </summary>
public bool AnyControlAt(Point world)
{
var page = PageAt(world.Y);
if (page is null)
{
return false;
}
return AnyInList(page.Controls, new Point(world.X, world.Y - page.OffsetY));
}
private static bool AnyInList(IList<ControlViewModel> controls, Point local)
{
foreach (var vm in controls)
{
var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height));
if (!rect.Contains(local))
{
continue;
}
return true;
}
return false;
}
private static ControlViewModel? HitInList(IList<ControlViewModel> controls, Point local)
{
for (var i = controls.Count - 1; i >= 0; i--)
{
var vm = controls[i];
if (vm.Model.Hidden || vm.Model.Locked)
{
continue;
}
var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height));
if (!rect.Contains(local))
{
continue;
}
if (vm is ContainerViewModel container)
{
var childLocal = new Point(local.X - vm.X, local.Y - vm.Y);
var childHit = HitInList(container.Children, childLocal);
if (childHit is not null)
{
return childHit;
}
}
return vm;
}
return null;
}
/// <summary>월드 rect 와 교차하는 컨트롤(마퀴 선택)</summary>
public List<ControlViewModel> ControlsIntersecting(Rect worldRect)
{
var result = new List<ControlViewModel>();
foreach (var page in Pages)
{
foreach (var vm in page.Controls)
{
if (vm.Model.Hidden || vm.Model.Locked)
{
continue;
}
if (worldRect.IntersectsWith(WorldBoundsOf(vm)))
{
result.Add(vm);
}
}
}
return result;
}
/// <summary>오버레이(선택 박스/핸들) 재계산</summary>
public void RefreshOverlay()
{
Overlay.UpdateSelection(SelectionWorldBounds(), Selection.Items.Count > 0);
if (Selection.Primary is not null)
{
Selection.ActivePage = PageOf(Selection.Primary) ?? Selection.ActivePage;
}
}
#endregion
#region Events
/// <summary>인라인 텍스트 편집 요청 — 캔버스 뷰(EditorLayer)가 구독</summary>
public event Action<ControlViewModel>? InlineEditRequested;
/// <summary>캔버스를 특정 컨트롤로 스크롤 요청 — 캔버스 뷰가 구독(뷰포트를 VM 이 모르므로 위임)</summary>
public event Action<ControlViewModel>? ScrollToRequested;
#endregion
#region Methods - 컨트롤로 이동
/// <summary>
/// 레이어 목록에서 고른 컨트롤을 선택하고 화면을 그리로 옮긴다.
///
/// 컨트롤이 수백 개인 서식(운영 최대 1,067개)에서는 목록에서 이름을 찾아도 그게 종이 어디에
/// 있는지 알 수 없었다. 다중 페이지 서식이면 다른 페이지에 있을 수도 있어 더 그렇다.
/// 스크롤은 뷰포트 크기를 알아야 하므로 캔버스 뷰에 위임한다.
/// </summary>
/// <summary>
/// 형제 중 다음(또는 이전) 컨트롤을 고른다 — Tab / Shift+Tab.
///
/// <b>왜 z-order 가 아니라 읽는 순서인가.</b> 계획서는 z-order 순회를 적었지만,
/// 이 도구에서 Tab 을 누르는 이유는 "서식을 위에서 아래로 훑으며 문구를 고치는 것"이다.
/// 기록지는 위에서 아래·왼쪽에서 오른쪽으로 읽으므로 그 순서가 손에 맞는다.
/// z-order 는 겹침을 정하는 축이라 화면상 순서와 무관하게 튄다.
/// (EMR 런타임 입력 순서인 <see cref="ApplyTabOrder"/> 와는 다른 개념이다 — 그건 저장되는 값이다.)
///
/// 범위는 <b>형제</b>다. 컨테이너 안으로는 Enter, 밖으로는 Esc 로 오간다 —
/// 전체를 한 줄로 이으면 패널 안 라디오 15,026개를 지나야 다음 항목에 닿는다.
/// </summary>
public void SelectSibling(bool backward)
{
var page = Selection.ActivePage ?? SelectedPage ?? Pages.FirstOrDefault();
if (page is null)
{
return;
}
var current = Selection.Primary;
var siblings = SiblingsOf(current, page);
if (siblings.Count == 0)
{
return;
}
// 읽는 순서 — 같은 줄로 볼 만큼 가까우면(8px) X 로 가른다. 그리드가 4px 이라 그 두 칸이다.
var ordered = siblings
.OrderBy(c => Math.Round(c.Y / 8))
.ThenBy(c => c.X)
.ThenBy(c => c.Model.Id, StringComparer.Ordinal)
.ToList();
var index = current is null ? -1 : ordered.IndexOf(current);
var next = index < 0
? ordered[backward ? ^1 : 0]
: ordered[((index + (backward ? -1 : 1)) % ordered.Count + ordered.Count) % ordered.Count];
Selection.SetSingle(next);
// 눈에 보이지 않는 것을 고쳐 놓는 일이 생기지 않게 화면으로 데려온다 —
// 레이어 목록 화살표 이동에 이것이 없어서 화면 밖 컨트롤을 눈 없이 편집할 수 있었다.
RevealControl(next);
}
/// <summary>Enter — 컨테이너면 안으로 들어가고, 글자를 담는 것이면 편집을 시작한다</summary>
public bool EnterSelection()
{
if (Selection.Primary is not { } primary)
{
return false;
}
if (primary is ContainerViewModel container && container.Children.FirstOrDefault() is { } child)
{
Selection.SetSingle(child);
RevealControl(child);
return true;
}
if (IsTextEditable(primary))
{
RequestInlineEdit(primary);
return true;
}
return false;
}
/// <summary>Esc — 컨테이너 밖으로 한 겹 나간다. 나갈 곳이 없으면 false(호출부가 선택을 푼다)</summary>
public bool SelectParent()
{
if (Selection.Primary?.Parent is not { } parent)
{
return false;
}
Selection.SetSingle(parent);
RevealControl(parent);
return true;
}
/// <summary>이 컨트롤과 같은 부모를 가진 것들 — 부모가 없으면 페이지 최상위</summary>
private List<ControlViewModel> SiblingsOf(ControlViewModel? viewModel, PageViewModel page)
=> viewModel?.Parent is ContainerViewModel parent
? parent.Children.ToList()
: ControlsOfPage(page).Where(c => c.Parent is null).ToList();
public void RevealControl(ControlViewModel viewModel)
{
// 다른 페이지의 컨트롤이면 그 페이지를 활성으로 — 안 그러면 선택만 되고 화면은 안 따라간다
var page = PageOf(viewModel);
if (page is not null && !ReferenceEquals(page, SelectedPage))
{
SelectedPage = page;
}
Selection.SetSingle(viewModel);
ScrollToRequested?.Invoke(viewModel);
}
#endregion
#region Methods - 인라인 텍스트 편집
/// <summary>더블클릭 인라인 편집 가능한 타입인지 — 레지스트리에 Text 속성 정의가 있거나 데이터소스(쿼리 편집)</summary>
public static bool IsTextEditable(ControlViewModel viewModel)
=> viewModel is DataTableViewModel
|| ControlRegistry.Find(viewModel.Type)?.Properties.Any(p => p.Key == "Text") == true;
/// <summary>인라인 편집 요청 발신 — InteractionController 의 더블클릭 경로(뷰가 대상 타입별 편집기 분기)</summary>
public void RequestInlineEdit(ControlViewModel viewModel)
{
if (!IsTextEditable(viewModel))
{
return;
}
Selection.SetSingle(viewModel);
InlineEditRequested?.Invoke(viewModel);
}
/// <summary>인라인 편집 커밋(Text) — 변경 시에만 Undo 1스텝 + 시각 재해석</summary>
public void CommitInlineText(ControlViewModel viewModel, string newText)
=> CommitPropertyText(viewModel, "Text", newText);
/// <summary>속성 텍스트 커밋 공통 — 변경 시에만 Undo 1스텝 + 시각 재해석 + 인스펙터 갱신</summary>
public void CommitPropertyText(ControlViewModel viewModel, string key, string newValue)
{
if (newValue == (viewModel.Model.Props.GetText(key) ?? string.Empty))
{
return;
}
Undo.Snapshot();
viewModel.Model.Props.SetText(key, newValue);
viewModel.RefreshVisual();
viewModel.NotifyAllChanged();
Inspector.Rebuild();
}
#endregion
#region Methods - 탭순서 편집
/// <summary>탭순서 대상 타입(입력 컨트롤 — 레거시 제외 규칙: Label/MDataTable 등 비입력 제외)</summary>
private static readonly HashSet<string> TabOrderTypes = new(StringComparer.Ordinal)
{
"TextBox", "MaskedTextBox", "CheckBox", "RadioButton", "ComboBox",
"ListBox", "CheckList", "DateTimePicker", "CalcBox", "Button",
};
/// <summary>탭순서 편집 모드 여부 — 모드 중 클릭은 순번 지정으로 동작</summary>
public bool IsTabOrderMode
{
get => isTabOrderMode;
private set
{
if (SetProperty(ref isTabOrderMode, value))
{
OnPropertyChanged(nameof(TabOrderMenuHeader));
}
}
}
/// <summary>탭순서 메뉴 표시 텍스트</summary>
public string TabOrderMenuHeader => IsTabOrderMode ? "탭순서 편집 완료(적용)" : "탭순서 편집 시작";
/// <summary>모드 토글 — 시작: 배지 표시, 완료: 클릭 순서대로 TabIndex 적용(레거시 규약 0,100,200…)</summary>
public void ToggleTabOrderMode()
{
if (IsTabOrderMode)
{
ApplyTabOrder();
ExitTabOrderMode();
}
else
{
tabOrderSequence.Clear();
Selection.Clear();
IsTabOrderMode = true;
RefreshTabBadges();
}
}
/// <summary>모드 취소(Esc) — 적용 없이 종료</summary>
public void CancelTabOrderMode() => ExitTabOrderMode();
/// <summary>모드 중 컨트롤 클릭 — 순번 지정/해제 토글</summary>
public void ToggleTabOrderClick(ControlViewModel viewModel)
{
if (!TabOrderTypes.Contains(viewModel.Type))
{
return;
}
if (!tabOrderSequence.Remove(viewModel))
{
tabOrderSequence.Add(viewModel);
}
RefreshTabBadges();
}
private void ExitTabOrderMode()
{
IsTabOrderMode = false;
tabOrderSequence.Clear();
Overlay.TabBadges.Clear();
}
/// <summary>
/// 탭순서 적용 — 초기화(999999+i) 후 클릭 순서 0,100,200…(레거시 TK_TABINDEXSAVE 이식).
///
/// 초기화 범위는 <b>이번에 손댄 페이지</b>로 한정한다. 레거시는 페이지마다 별도 디자인
/// 서피스라 한 페이지를 편집해도 다른 페이지 TabIndex 는 그대로 남는데, 문서 전체를 밀면
/// 손대지 않은 페이지의 탭 순서가 통째로 999999+i 로 덮여 입력 순서가 무너진다.
/// 여러 페이지를 걸쳐 클릭했다면 그 페이지들이 모두 대상이 된다(한 페이지만 클릭한
/// 보통의 경우는 레거시와 동일하게 동작한다).
/// </summary>
private void ApplyTabOrder()
{
if (tabOrderSequence.Count == 0)
{
return;
}
Undo.Snapshot();
var touched = new HashSet<PageViewModel>();
foreach (var vm in tabOrderSequence)
{
if (PageOf(vm) is { } page)
{
touched.Add(page);
}
}
var index = 0;
foreach (var vm in AllControls().Where(v => PageOf(v) is { } p && touched.Contains(p)))
{
index++;
vm.Model.Props.SetText("TabIndex", (999999 + index).ToString());
}
// TabIndex 는 <b>컨테이너 범위</b>다(Windows 표준 탐색). 문서 평면에 0,100,200… 을 뿌리면
// 패널 안의 것들이 저희끼리만 겨루게 되어 탭이 그 패널을 통째로 지나친 뒤에야 들어간다.
// 그래서 부모별로 따로 매기고, 패널 자신에게는 제 자식 중 가장 이른 순번을 준다.
// Dictionary 는 null 키를 받지 않는다 — 최상위는 따로 센다
var topRank = 0;
var byParent = new Dictionary<ControlViewModel, int>();
var panelRank = new Dictionary<ControlViewModel, int>();
for (var i = 0; i < tabOrderSequence.Count; i++)
{
var vm = tabOrderSequence[i];
var parent = vm.Parent;
int rank;
if (parent is null)
{
rank = topRank++;
}
else
{
rank = byParent.TryGetValue(parent, out var next) ? next : 0;
byParent[parent] = rank + 1;
}
vm.Model.Props.SetText("TabIndex", rank == 0 ? "0" : (100 * rank).ToString());
// 패널 자신의 순번은 그 안에서 가장 먼저 클릭된 것의 자리다
if (parent is not null && !panelRank.ContainsKey(parent))
{
panelRank[parent] = i;
}
}
foreach (var (panel, rank) in panelRank)
{
panel.Model.Props.SetText("TabIndex", rank == 0 ? "0" : (100 * rank).ToString());
}
Inspector.Rebuild();
}
/// <summary>대상 컨트롤 배지 갱신 — 지정된 것은 순번(파랑), 미지정은 ""(회색)</summary>
private void RefreshTabBadges()
{
Overlay.TabBadges.Clear();
foreach (var vm in AllControls().Where(v => TabOrderTypes.Contains(v.Type) && !v.Model.Hidden))
{
var bounds = WorldBoundsOf(vm);
var order = tabOrderSequence.IndexOf(vm);
Overlay.TabBadges.Add(new TabBadgeInfo
{
X = bounds.X - 6,
Y = bounds.Y - 6,
Text = order >= 0 ? (order + 1).ToString() : "",
IsAssigned = order >= 0,
});
}
}
/// <summary>
/// 문서의 모든 컨트롤 이름 — 쿼리의 《컨트롤명》 치환 후보.
/// 런타임도 같은 서식 안에서만 이름을 찾으므로(clsMDataTable 의 poAllControlOnSheet) 범위가 같다.
/// </summary>
public IReadOnlyList<string> ControlNames()
=> AllControls().Select(c => c.Id).Where(id => id.Length > 0).Distinct(StringComparer.Ordinal).ToList();
/// <summary>
/// 한 페이지의 컨트롤 순회(컨테이너 자식 포함) — 스냅 후보 수집용.
/// 스냅은 앵커 페이지 안에서만 본다. 전 페이지를 넣으면 후보가 폭발하고,
/// 다른 종이의 좌표에 붙는 것은 뜻도 없다.
/// </summary>
public IEnumerable<ControlViewModel> ControlsOfPage(PageViewModel page)
{
foreach (var vm in page.Controls)
{
foreach (var descendant in Descend(vm))
{
yield return descendant;
}
}
}
/// <summary>선택 항목과 그 자손 전부 — 끌려가는 컨테이너의 자식을 스냅 후보에서 빼는 데 쓴다</summary>
public HashSet<ControlViewModel> SelectionWithDescendants()
{
var set = new HashSet<ControlViewModel>();
foreach (var item in Selection.Items)
{
foreach (var vm in Descend(item))
{
set.Add(vm);
}
}
return set;
}
private static IEnumerable<ControlViewModel> Descend(ControlViewModel viewModel)
{
yield return viewModel;
if (viewModel is ContainerViewModel container)
{
foreach (var child in container.Children)
{
foreach (var descendant in Descend(child))
{
yield return descendant;
}
}
}
}
/// <summary>문서 전체 컨트롤 순회(컨테이너 자식 포함, 모델 순서)</summary>
private IEnumerable<ControlViewModel> AllControls()
{
IEnumerable<ControlViewModel> Visit(ControlViewModel vm)
{
yield return vm;
if (vm is ContainerViewModel container)
{
foreach (var child in container.Children)
{
foreach (var descendant in Visit(child))
{
yield return descendant;
}
}
}
}
foreach (var page in Pages)
{
foreach (var vm in page.Controls)
{
foreach (var descendant in Visit(vm))
{
yield return descendant;
}
}
}
}
#endregion
#region Methods - 폰트 일괄 변경
/// <summary>텍스트 렌더 대상 타입(폰트 집계/일괄 변경 대상 — 레거시 frmFontManager 대상군 확장)</summary>
private static readonly HashSet<string> FontTargetTypes = new(StringComparer.Ordinal)
{
"Label", "TextBox", "MaskedTextBox", "CheckBox", "RadioButton", "ComboBox",
"ListBox", "CheckList", "DateTimePicker", "GroupBox", "CalcBox", "Button",
};
/// <summary>
/// 문서 전체(컨테이너 자식 포함) 폰트 사용 집계 — (타입, 글꼴, 크기, 스타일) 조합별 컨트롤 목록.
/// Font 미지정(상속) 컨트롤은 유효 폰트 기준으로 집계된다.
/// </summary>
public List<FontUsageGroup> CollectFontUsage()
{
var groups = new Dictionary<string, FontUsageGroup>(StringComparer.Ordinal);
void Visit(ControlViewModel vm)
{
if (FontTargetTypes.Contains(vm.Type))
{
var font = vm.EffectiveFont;
var explicitFont = vm.Model.Props.Contains("Font");
var key = $"{vm.Type}|{font.Family}|{font.SizePt}|{font.Bold}|{font.Italic}|{font.Underline}|{explicitFont}";
if (!groups.TryGetValue(key, out var group))
{
group = new FontUsageGroup(vm.Type, font.Family, font.SizePt,
font.Bold, font.Italic, font.Underline, !explicitFont);
groups[key] = group;
}
group.Controls.Add(vm);
}
if (vm is ContainerViewModel container)
{
foreach (var child in container.Children)
{
Visit(child);
}
}
}
foreach (var page in Pages)
{
foreach (var vm in page.Controls)
{
Visit(vm);
}
}
return groups.Values
.OrderBy(g => g.Type, StringComparer.Ordinal)
.ThenBy(g => g.Family, StringComparer.Ordinal)
.ThenBy(g => g.SizePt)
.ToList();
}
/// <summary>선택 조합의 컨트롤 전체에 새 폰트 일괄 적용 — Undo 1스텝</summary>
public int ApplyFontBulk(IEnumerable<FontUsageGroup> targets, LegacyFont newFont)
{
var controls = targets.SelectMany(g => g.Controls).Distinct().ToList();
if (controls.Count == 0)
{
return 0;
}
Undo.Snapshot();
var fontText = LegacyFormat.FormatFont(newFont);
foreach (var vm in controls)
{
vm.Model.Props.SetText("Font", fontText);
vm.RefreshVisual();
vm.NotifyAllChanged();
}
Inspector.Rebuild();
return controls.Count;
}
#endregion
#region Methods - 편집
/// <summary>팔레트 즉시 배치 — 활성 페이지 중앙 부근, 겹침 방지 오프셋(더블클릭/플라이아웃 공용)</summary>
public void AddPaletteItemAtCenter(string type)
{
var page = SelectedPage ?? Pages.FirstOrDefault();
if (page is null)
{
return;
}
// 겹침 방지 오프셋은 <b>단조 증가하는 자체 카운터</b>로 센다.
//
// 전에는 (page.Controls.Count % 8) * 16 이었다. 두 가지가 잘못이다 —
// ① % 8 이라 <b>9번째가 1번째와 정확히 같은 자리</b>에 떨어진다.
// ② Controls.Count 를 쓰므로 하나 지우고 다시 놓으면 지운 것과 같은 자리로 간다.
// 계단은 종이를 벗어나기 전에 접는다 — 접히더라도 방금 놓은 것 위로는 가지 않는다.
var step = placementStep++;
var lane = step / PlacementLaneSteps;
var offset = (step % PlacementLaneSteps) * 16;
AddControlAt(type, new Point(
page.WidthDip / 2 + offset + (lane * 12),
page.OffsetY + page.HeightDip / 3 + offset));
}
/// <summary>팔레트 드롭 — 월드 좌표에 신규 컨트롤 생성(모델 index 0 = 최상위)</summary>
public void AddControlAt(string type, Point world)
{
var descriptor = ControlRegistry.Find(type);
if (descriptor is null)
{
return;
}
var page = PageAt(world.Y);
if (page is null)
{
return;
}
Undo.Snapshot();
var local = new Point(world.X, world.Y - page.OffsetY);
var element = new ControlElement
{
Type = descriptor.Type,
Id = IdGenerator.NextId(document, descriptor.Type),
Bounds = new LayoutRect { W = descriptor.DefaultW, H = descriptor.DefaultH },
};
if (type is "Label" or "CheckBox" or "RadioButton" or "GroupBox")
{
element.Props.SetText("Text", descriptor.DisplayName);
}
// 컨테이너 내부 드롭(깊이 1 — 컨테이너 안에 컨테이너 금지) → 상대좌표로 자식 배치
var container = descriptor.IsContainer ? null : ContainerAt(page, local);
if (container is not null)
{
element.Bounds.X = Math.Max(0, Math.Round(local.X - container.X - descriptor.DefaultW / 2));
element.Bounds.Y = Math.Max(0, Math.Round(local.Y - container.Y - descriptor.DefaultH / 2));
container.Model.Children.Insert(0, element);
var childViewModel = DocumentMapper.CreateControl(element, container.EffectiveFont, container.Foreground);
childViewModel.Parent = container;
container.Children.Add(childViewModel);
Selection.SetSingle(childViewModel);
// 컨테이너 자식은 page.Controls 밖이라 CollectionChanged 가 오지 않는다
RebuildLayerRows();
return;
}
element.Bounds.X = Math.Max(0, Math.Round(local.X - descriptor.DefaultW / 2));
element.Bounds.Y = Math.Max(0, Math.Round(local.Y - descriptor.DefaultH / 2));
page.Model.Controls.Insert(0, element);
var viewModel = CreateAndAttach(element, page, attachToEnd: true);
Selection.SetSingle(viewModel);
}
/// <summary>페이지 로컬 좌표의 최상위 컨테이너(Panel/GroupBox) — 없으면 null</summary>
private static ContainerViewModel? ContainerAt(PageViewModel page, Point local)
{
for (var i = page.Controls.Count - 1; i >= 0; i--)
{
if (page.Controls[i] is ContainerViewModel container
&& !container.Model.Locked && !container.Model.Hidden
&& new Rect(container.X, container.Y, Math.Max(1, container.Width), Math.Max(1, container.Height)).Contains(local))
{
return container;
}
}
return null;
}
/// <summary>선택 삭제</summary>
public void DeleteSelection()
{
if (Selection.Items.Count == 0)
{
return;
}
Undo.Snapshot();
foreach (var vm in Selection.Items.ToList())
{
if (vm.Parent is ContainerViewModel parent)
{
parent.Children.Remove(vm);
parent.Model.Children.Remove(vm.Model);
continue;
}
var page = PageOf(vm);
if (page is null)
{
continue;
}
page.Controls.Remove(vm);
page.Model.Controls.Remove(vm.Model);
}
Selection.Clear();
// 컨테이너 자식 삭제는 page.Controls 를 건드리지 않아 CollectionChanged 가 오지 않는다
RebuildLayerRows();
}
/// <summary>선택 복사 — 내부 클립보드(딥클론, 모델 순서 유지)</summary>
public void CopySelection()
{
if (Selection.Items.Count == 0)
{
return;
}
clipboard.Clear();
var page = Selection.ActivePage;
var ordered = Selection.Items
.OrderBy(vm => page?.Model.Controls.IndexOf(vm.Model) ?? 0)
.Select(vm => vm.Model.Clone());
clipboard.AddRange(ordered);
}
/// <summary>붙여넣기 — 활성 페이지에 +12,+12 오프셋, 새 Id 부여 (표는 제외 — <see cref="ContainsSpread"/> 참조)</summary>
public void Paste()
{
if (clipboard.Count == 0)
{
return;
}
var page = Selection.ActivePage ?? Pages.FirstOrDefault();
if (page is null)
{
return;
}
var (pastable, blocked) = FilterPastable(clipboard);
if (blocked > 0)
{
Services.DialogService.Notify(Services.DialogKind.Warning, "붙여넣기",
$"표(Spread) {blocked}개는 붙여넣지 않았습니다.",
"표의 격자 디자인은 서식코드+컨트롤이름으로 별도 테이블(E_SpdMst)에서 조회됩니다.\n" +
"복제본에는 격자가 없어 EMR 에서 해당 서식이 열리지 않습니다.\n" +
"표 추가는 레거시 서식생성기를 사용하세요.");
}
if (pastable.Count == 0)
{
return;
}
Undo.Snapshot();
var pasted = new List<ControlViewModel>();
var usedIds = IdGenerator.CollectUsed(document);
// 그룹은 복제본끼리 다시 묶는다 — Clone 이 GroupId 를 그대로 복사하므로 그냥 두면
// 붙여넣은 컨트롤이 원본 그룹에 끼어들어 원본 하나만 클릭해도 복제본까지 함께 선택된다
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
// 모델 순서(0=최상위) 유지: 역순으로 index 0 삽입
foreach (var source in Enumerable.Reverse(pastable))
{
var clone = source.Clone();
AssignNewIds(clone, usedIds);
RemapGroupId(clone, groupRemap);
clone.Bounds.X += 12;
clone.Bounds.Y += 12;
page.Model.Controls.Insert(0, clone);
pasted.Add(CreateAndAttach(clone, page, attachToEnd: true));
}
Selection.Set(pasted, pasted.LastOrDefault());
RebuildLayerRows();
}
/// <summary>
/// 선택분을 <b>제자리</b>에 복제하고 복제본을 선택한다 — Ctrl+드래그 복사가 쓴다.
///
/// <b>붙여넣기와 다른 점</b>: 오프셋을 주지 않고, 원래 부모(컨테이너 자식이면 그 컨테이너)
/// 안에 넣는다. 그래서 라디오를 Ctrl+드래그로 복사하면 <b>같은 패널 안</b>에 남아
/// 같은 문항의 선택지가 하나 늘어난다 — 패널 밖으로 튀어나가면 배타 묶음이 갈라진다.
///
/// 표(Spread)는 복제하지 않는다(격자가 서식코드+이름으로 별도 테이블에서 오므로
/// 복제본에는 격자가 없다 — <see cref="FilterPastable"/> 와 같은 이유).
/// 복제에 실패하면(전부 표였다) false — 호출부가 이동으로 되돌린다.
/// </summary>
public bool DuplicateSelectionInPlace()
{
var sources = Selection.Items.ToList();
if (sources.Count == 0)
{
return false;
}
var (pastable, _) = FilterPastable(sources.Select(vm => vm.Model).ToList());
if (pastable.Count == 0)
{
return false;
}
Undo.Snapshot();
var usedIds = IdGenerator.CollectUsed(document);
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
var copies = new List<ControlViewModel>();
foreach (var vm in sources)
{
if (!pastable.Contains(vm.Model))
{
continue;
}
var page = PageOf(vm);
if (page is null)
{
continue;
}
var clone = vm.Model.Clone();
AssignNewIds(clone, usedIds);
RemapGroupId(clone, groupRemap);
if (vm.Parent is ContainerViewModel parent)
{
// 컨테이너 자식 — 같은 부모 안, 같은 상대좌표에 둔다
parent.Model.Children.Insert(0, clone);
var childVm = DocumentMapper.CreateControl(clone, parent.EffectiveFont, parent.Foreground);
childVm.Parent = parent;
parent.Children.Add(childVm);
copies.Add(childVm);
}
else
{
page.Model.Controls.Insert(0, clone);
copies.Add(CreateAndAttach(clone, page, attachToEnd: true));
}
}
if (copies.Count == 0)
{
return false;
}
Selection.Set(copies, copies.LastOrDefault());
RebuildLayerRows();
return true;
}
/// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary>
private void RemapGroupId(ControlElement element, Dictionary<string, string> remap)
{
if (element.GroupId is { } original)
{
if (!remap.TryGetValue(original, out var replacement))
{
replacement = Guid.NewGuid().ToString("N");
remap[original] = replacement;
if (document.GroupNames.TryGetValue(original, out var name))
{
document.GroupNames[replacement] = name;
}
}
element.GroupId = replacement;
}
foreach (var child in element.Children)
{
RemapGroupId(child, remap);
}
}
/// <summary>
/// 표(Spread) 타입 — E_SpdMst 격자를 서식코드+이름으로 조회하므로 이름 변경·복제가 금지된다.
/// VM 형이 아니라 모델 Type 문자열로 판정한다(클립보드가 ControlElement 이고 컨테이너 중첩도 잡아야 함).
/// </summary>
public static bool IsSpread(ControlElement element)
=> string.Equals(element.Type, "Spread", StringComparison.Ordinal);
/// <summary>자신 또는 자손에 표(Spread)가 있는지</summary>
public static bool ContainsSpread(ControlElement element)
=> IsSpread(element) || element.Children.Any(ContainsSpread);
/// <summary>
/// 붙여넣기 가능 항목과 차단된 표 개수 — 알림과 분리해 무인 검증이 가능하도록 둔 순수 함수.
/// 표의 격자는 서식코드+컨트롤이름으로만 조회되는 E_SpdMst 에 있고 SheetMe 는 그 행을 만들지 못한다.
/// 복제본은 이름이 달라 조회 0건 → 레거시가 빈 파일로 FpSpread.Open 을 시도하다 예외 →
/// LoadObject 에 try/catch 가 없어 최상위로 전파되어 그 서식이 통째로 열리지 않는다.
/// 선택 전체를 막지 않고 표만 걸러내는 이유: "라벨 20개 + 표 1개" 복사 같은 정상 작업까지 막히기 때문.
/// </summary>
public static (List<ControlElement> Pastable, int BlockedSpreads) FilterPastable(IEnumerable<ControlElement> source)
{
var all = source.ToList();
var pastable = all.Where(element => !ContainsSpread(element)).ToList();
return (pastable, all.Count - pastable.Count);
}
/// <summary>선택 z-order 변경 — 모델 index 0 = 최상위 / VM 마지막 = 최상위</summary>
public void ReorderSelection(bool toFront)
{
if (Selection.Items.Count == 0)
{
return;
}
Undo.Snapshot();
foreach (var vm in Selection.Items.Where(v => v.Parent is null).ToList())
{
var page = PageOf(vm);
if (page is null)
{
continue;
}
page.Model.Controls.Remove(vm.Model);
page.Controls.Remove(vm);
if (toFront)
{
page.Model.Controls.Insert(0, vm.Model);
page.Controls.Add(vm);
}
else
{
page.Model.Controls.Add(vm.Model);
page.Controls.Insert(0, vm);
}
}
RefreshOverlay();
}
/// <summary>
/// 같은 부모 안에서 임의 위치로 z-order 이동 — 레이어 목록 드래그 재정렬의 모델 연산.
///
/// <see cref="ReorderSelection"/> 은 맨 앞/맨 뒤 두 극단뿐이고 컨테이너 자식은 아예 건너뛴다.
/// 여기서는 최상위든 컨테이너 자식이든 제 부모 컬렉션 안에서 원하는 자리로 옮긴다.
/// 부모가 다른 컨트롤 사이의 이동(재부모화)은 하지 않는다 — 좌표계가 바뀌는 별개의 연산이다.
///
/// 좌표계 주의: VM 컬렉션은 <b>그리기 순서</b>(마지막이 최상위), 모델은 <b>XML 순서</b>(index 0 이
/// 최상위)로 서로 역순이다. 두 리스트를 같이 옮기되 index 를 뒤집어 대응시킨다.
/// </summary>
/// <param name="viewModel">옮길 컨트롤</param>
/// <param name="newDrawIndex">목표 위치(부모의 VM 컬렉션 = 그리기 순서 기준 index)</param>
/// <returns>실제로 순서가 바뀌었으면 true</returns>
public bool MoveWithinParent(ControlViewModel viewModel, int newDrawIndex)
{
var (viewModels, models) = ParentListsOf(viewModel);
if (viewModels is null || models is null)
{
return false;
}
var oldDrawIndex = viewModels.IndexOf(viewModel);
if (oldDrawIndex < 0)
{
return false;
}
var clamped = Math.Clamp(newDrawIndex, 0, viewModels.Count - 1);
if (clamped == oldDrawIndex)
{
return false;
}
Undo.Snapshot();
viewModels.RemoveAt(oldDrawIndex);
viewModels.Insert(clamped, viewModel);
models.Remove(viewModel.Model);
// 그리기 index → 모델 index (역순). Remove 후 개수가 하나 줄었으므로 Count 를 그대로 쓴다.
models.Insert(models.Count - clamped, viewModel.Model);
RefreshOverlay();
RebuildLayerRows();
return true;
}
/// <summary>
/// 레이어 목록 드래그 재정렬 — <paramref name="source"/> 를 <paramref name="target"/> 의
/// 위(<paramref name="above"/>=true) 또는 아래로 옮긴다.
///
/// 목록은 z-order 최상위가 맨 위인데 VM 컬렉션은 그리기 순서(마지막이 최상위)라 방향이 반대다.
/// 그래서 "목록에서 위" = "VM 컬렉션에서 뒤"로 뒤집어 계산한다.
/// </summary>
public bool MoveControlNextTo(ControlViewModel source, ControlViewModel target, bool above)
{
var (viewModels, _) = ParentListsOf(source);
if (viewModels is null || !viewModels.Contains(target) || ReferenceEquals(source, target))
{
return false;
}
var from = viewModels.IndexOf(source);
var targetIndex = viewModels.IndexOf(target);
// 목록 위 = 그리기 순서 뒤. 대상 '위'에 놓으려면 대상보다 뒤(=index 큼)로 가야 한다.
var to = above ? targetIndex + 1 : targetIndex;
// 자기 자신을 뺀 뒤의 자리로 보정
if (from < to)
{
to--;
}
return MoveWithinParent(source, to);
}
/// <summary>컨트롤이 속한 부모의 (VM 컬렉션, 모델 컬렉션) — 최상위면 페이지, 아니면 컨테이너</summary>
private (System.Collections.ObjectModel.ObservableCollection<ControlViewModel>? ViewModels, List<ControlElement>? Models)
ParentListsOf(ControlViewModel viewModel)
{
if (viewModel.Parent is ContainerViewModel container)
{
return (container.Children, container.Model.Children);
}
var page = PageOf(viewModel);
return page is null ? (null, null) : (page.Controls, page.Model.Controls);
}
/// <summary>방향키 넛지 — 400ms 코얼레스 Undo</summary>
public void NudgeSelection(double dx, double dy)
{
if (Selection.Items.Count == 0)
{
return;
}
Undo.SnapshotForNudge();
foreach (var vm in Selection.Items)
{
vm.X = Math.Round(vm.X + dx);
vm.Y = Math.Round(vm.Y + dy);
}
Selection.NotifyBoundsChanged();
}
/// <summary>활성 페이지 전체 선택</summary>
public void SelectAllOnActivePage()
{
var page = Selection.ActivePage ?? Pages.FirstOrDefault();
if (page is null)
{
return;
}
Selection.Set(page.Controls.Where(c => !c.Model.Hidden), page.Controls.LastOrDefault());
}
/// <summary>
/// 선택 정렬 — <b>기준은 언제나 '담고 있는 것'</b>이다.
///
/// 선택이 2개 이상이면 선택 영역이 그 자리를 대신하고, 1개면 그 컨트롤을 담고 있는 것 —
/// 컨테이너 안이면 컨테이너, 최상위면 페이지 — 이 기준이 된다. 피그마와 같은 규칙이다.
///
/// <b>컨테이너 자식을 페이지 기준으로 맞추지 않는 이유.</b> X/Y 는 부모 상대좌표라
/// "페이지 왼쪽"에 맞추면 음수가 되어 부모 밖으로 나간다. 디자이너 캔버스는 클리핑을 안 해서
/// 멀쩡히 보이지만 레거시 런타임에서는 부모가 잘라먹는다 — 화면과 인쇄가 갈라지고 눈으로 못 잡는다.
/// </summary>
public void Align(string mode)
{
if (Selection.Items.Count == 0 || SheetMe.Core.Layout.AlignSolver.Parse(mode) is not { } direction)
{
return;
}
// 부모와 그 자식을 함께 선택할 수 있다(레이어 목록). 부모를 옮기면 자식 월드 좌표가 따라 움직이는데
// 자식에게도 절대좌표를 대입하면 두 번 옮겨져 어긋난다 — 조상이 선택에 있는 것은 뺀다.
var targets = Selection.Items.Where(vm => !HasSelectedAncestor(vm)).ToList();
if (targets.Count == 0)
{
return;
}
// 다른 종이의 '위 맞춤'에는 뜻이 없다. 예전에는 이 경우 컨트롤이 제 종이 위 허공으로 날아갔다
// (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);
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>
public bool AlignToPage
{
get => alignToPage;
set => SetProperty(ref alignToPage, value);
}
/// <summary>정렬 기준 사각형(월드)</summary>
private SheetMe.Core.Layout.SnapRect? ReferenceFor(IReadOnlyList<ControlViewModel> targets)
{
if (alignToPage)
{
return PageOf(targets[0]) is { } target
? new SheetMe.Core.Layout.SnapRect(0, target.OffsetY, target.WidthDip, target.HeightDip)
: null;
}
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 (alignToPage)
{
return "페이지 기준";
}
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 string AlignSelectionScopeLabel()
{
var items = Selection.Items;
if (items.Count >= 2)
{
return "선택 영역";
}
return items.Count == 1 && items[0].Parent is not null ? "부모" : "페이지";
}
/// <summary>간격 균등이 뜻을 갖는가 — 사이가 있어야 나눌 것이 있다</summary>
public bool CanDistribute() => Selection.Items.Count >= 3;
/// <summary>세로 정렬이 가능한가 — 여러 페이지에 걸친 선택에서는 뜻이 없다</summary>
public bool CanAlignVertical()
=> Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
/// <summary>
/// 선택 그룹화 — 선택분(2개 이상, 최상위만)을 감싸는 <b>진짜 Panel</b> 을 만들어 자식으로 옮긴다.
/// 그래서 저장까지 살아남는다(레거시 XML 의 children="Controls" 중첩 Object).
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
///
/// <b>라디오에 특히 중요하다</b>: 레거시는 GroupName 이 없어 직계 부모가 곧 배타 단위이고,
/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 자식이다(그룹 5,582개, 평균 2.72개).
/// 즉 이 조작이 "문항 만들기" 다.
/// </summary>
public void GroupSelection()
{
var targets = Selection.Items.Where(vm => vm.Parent is null && PageOf(vm) is not null).ToList();
if (targets.Count < 2 || PageOf(targets[0]) is not { } page)
{
return;
}
if (GroupBlockReason(page, targets) is { } reason)
{
DialogService.Notify(DialogKind.Warning, "그룹", reason);
return;
}
// 라디오는 <b>패널이 곧 문항</b>이다 — 묶는 것이 정상이고 필요한 작업이다
// (운영 실측: 라디오 9,935개 중 9,819개(98.8%)가 패널 자식, 그룹 3,625개).
// 다만 한 문항의 일부만 묶으면 '하나만 고르기'가 둘로 갈라지므로 그때만 되묻는다.
if (RadioSplitWarning(page, targets) is { } warning
&& !DialogService.Confirm("그룹", warning,
"묶은 라디오끼리 하나만 선택되고, 남은 라디오는 별도 묶음이 됩니다."))
{
return;
}
// z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다.
// WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데,
// 사이에 남이 끼어 있으면 그 남이 앞뒤로 밀려 쌓임 순서가 바뀐다(위 가드가 그걸 막는다).
var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList();
var insertAt = indices.Min();
var bounds = targets
.Select(vm => new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height)))
.Aggregate(Rect.Union);
// 좌표는 여기서 정수로 확정한다. 부모 원점과 자식 상대좌표를 따로 반올림하면
// 저장 시 Location 과 LocationOnBase 가 축당 1px 어긋난 파일이 나온다(포맷이 정수만 쓴다).
var originX = Math.Round(bounds.X);
var originY = Math.Round(bounds.Y);
Undo.Snapshot();
var panel = new ControlElement
{
Type = "Panel",
Id = IdGenerator.NextId(document, "Panel"),
Bounds = new LayoutRect
{
X = originX,
Y = originY,
W = Math.Ceiling(bounds.Right - originX),
H = Math.Ceiling(bounds.Bottom - originY),
},
};
foreach (var vm in targets.OrderBy(vm => page.Model.Controls.IndexOf(vm.Model)))
{
page.Model.Controls.Remove(vm.Model);
vm.Model.Bounds.X = Math.Round(vm.X) - originX;
vm.Model.Bounds.Y = Math.Round(vm.Y) - originY;
panel.Children.Add(vm.Model);
}
page.Model.Controls.Insert(Math.Min(insertAt, page.Model.Controls.Count), panel);
RebuildFromDocument();
if (PageOf2(page.Index)?.Controls.FirstOrDefault(c => ReferenceEquals(c.Model, panel)) is { } created)
{
Selection.SetSingle(created);
}
}
/// <summary>
/// 묶을 수 없는 이유 — 없으면 null.
///
/// 그룹이 진짜 Panel 이 되면서 레거시의 두 가지 성질이 따라온다.
/// 여기서 막지 않으면 캔버스에는 아무 변화도 안 보이는 채로 EMR 동작이 달라진다.
/// </summary>
private string? GroupBlockReason(PageViewModel page, IReadOnlyList<ControlViewModel> targets)
{
// 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다.
var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList();
if (indices.Any(i => i < 0))
{
return null;
}
var span = indices.Max() - indices.Min() + 1;
if (span != targets.Count)
{
return "묶으려는 것들 사이에 다른 컨트롤이 겹쳐 있어, 묶으면 앞뒤 순서가 바뀝니다.\n"
+ "먼저 순서를 붙여 주세요(레이어 목록에서 나란히 오도록).";
}
return null;
}
/// <summary>
/// 라디오 문항이 갈라지는가 — 갈라지지 않으면 null.
///
/// <b>왜 되묻는가.</b> 레거시 RadioButton 은 WinForms 를 그대로 상속해
/// <c>GroupName</c> 이 없고 <b>직계 부모가 곧 배타 단위</b>다(RadioButton.vb:7-8).
/// 그래서 패널로 묶는 것 자체는 정상이고 필요한 작업이지만,
/// 같은 자리에 있던 라디오 중 <b>일부만</b> 묶으면 한 문항이 두 묶음으로 갈라져
/// EMR 에서 답이 둘 선택된다 — 캔버스에는 아무 변화도 안 보인 채로.
/// </summary>
internal string? RadioSplitWarning(PageViewModel page, IReadOnlyList<ControlViewModel> targets)
{
var picked = targets.Count(vm => vm.Type == "RadioButton");
if (picked == 0)
{
return null;
}
// 지금은 최상위만 묶을 수 있으므로(위 targets 필터) 남는 형제도 최상위에서 센다
var siblings = page.Model.Controls.Count(c =>
string.Equals(c.Type, "RadioButton", StringComparison.Ordinal)
&& !targets.Any(vm => ReferenceEquals(vm.Model, c)));
return siblings == 0
? null
: $"이 페이지에 라디오 버튼 {picked + siblings}개 중 {picked}개만 묶습니다.\n"
+ $"남는 {siblings}개는 다른 선택 묶음이 됩니다. 계속할까요?";
}
private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null;
/// <summary>
/// 선택 그룹 해제 — 선택에 걸린 그룹의 <b>전 멤버</b>를 푼다(선택되지 않은 멤버 포함).
/// 일부만 풀면 멤버 1개짜리 반쪽 그룹이 남아 다음 선택부터 동작이 이상해진다.
/// </summary>
public void UngroupSelection()
{
var groupIds = Selection.Items
.Select(vm => vm.Model.GroupId)
.Where(id => id is not null)
.Distinct(StringComparer.Ordinal)
.ToList();
// 그룹은 이제 진짜 패널이다 — 고른 것이 패널이거나 패널 안의 것이면 그 패널을 푼다.
var panel = Selection.Items.OfType<ContainerViewModel>().FirstOrDefault(c => c.Parent is null)
?? Selection.Items.Select(vm => vm.Parent).OfType<ContainerViewModel>()
.FirstOrDefault(c => c.Parent is null);
if (panel is not null)
{
UnwrapPanel(panel);
return;
}
if (groupIds.Count == 0)
{
return;
}
Undo.Snapshot();
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id && groupIds.Contains(id, StringComparer.Ordinal))
{
control.Model.GroupId = null;
}
}
foreach (var id in groupIds)
{
document.GroupNames.Remove(id!);
}
RebuildLayerRows();
}
/// <summary>
/// 패널 풀기 — 자식을 최상위로 되돌리고 패널을 지운다.
///
/// 자식 좌표를 절대값으로 되돌려야 한다. 안 그러면 패널 원점만큼 좌상단으로 튄다.
/// 패널이 있던 z 자리에 자식들을 그대로 끼워 넣어 쌓임 순서를 보존한다.
///
/// <b>레거시 디자이너에는 이 조작이 없다</b> — 거기서 패널을 지우면 자식까지 함께 지워진다.
/// </summary>
public void UnwrapPanel(ControlViewModel viewModel)
{
if (viewModel is not ContainerViewModel container
|| container.Parent is not null
|| PageOf(container) is not { } page)
{
return;
}
var at = page.Model.Controls.IndexOf(container.Model);
if (at < 0)
{
return;
}
Undo.Snapshot();
var originX = container.Model.Bounds.X;
var originY = container.Model.Bounds.Y;
var children = container.Model.Children.ToList();
container.Model.Children.Clear();
page.Model.Controls.RemoveAt(at);
for (var i = 0; i < children.Count; i++)
{
children[i].Bounds.X = Math.Round(children[i].Bounds.X + originX);
children[i].Bounds.Y = Math.Round(children[i].Bounds.Y + originY);
page.Model.Controls.Insert(at + i, children[i]);
}
RebuildFromDocument();
var restored = Pages[page.Index].Controls
.Where(c => children.Any(child => ReferenceEquals(child, c.Model)))
.ToList();
if (restored.Count > 0)
{
Selection.Set(restored, restored[0]);
}
}
/// <summary>
/// 이 컨트롤을 클릭했을 때 실제로 잡히는 것 — 최상위 컨테이너까지 거슬러 올라간다.
/// 그룹이 진짜 패널이 되면서, 자식을 집으면 그 하나만 끌려가던 문제를 여기서 막는다.
/// </summary>
public ControlViewModel SelectionUnitOf(ControlViewModel viewModel)
{
var unit = viewModel;
while (unit.Parent is { } parent)
{
unit = parent;
}
return unit;
}
/// <summary>그룹 동료 확장 — GroupId 가 같은 페이지 내 컨트롤 전체(클릭 선택이 그룹 단위가 되도록)</summary>
public List<ControlViewModel> GroupMatesOf(ControlViewModel viewModel)
{
// 패널 안의 것은 패널이 곧 선택 단위다 — 자식 하나를 집어도 묶음 전체가 함께 움직여야 한다.
// 안으로 들어가려면 더블클릭한다(InteractionController 가 처리).
var unit = SelectionUnitOf(viewModel);
if (!ReferenceEquals(unit, viewModel))
{
return new List<ControlViewModel> { unit };
}
if (viewModel.Model.GroupId is not { } groupId)
{
return new List<ControlViewModel> { viewModel };
}
var page = PageOf(viewModel);
if (page is null)
{
return new List<ControlViewModel> { viewModel };
}
return page.Controls.Where(c => c.Model.GroupId == groupId).ToList();
}
#endregion
#region Methods - 레이어 목록
/// <summary>그룹 표시 이름 — 없으면 기본값("그룹")</summary>
public string GroupNameOf(string groupId)
=> document.GroupNames.TryGetValue(groupId, out var name) && name.Trim().Length > 0
? name
: LayerGroupViewModel.FallbackName;
/// <summary>그룹 이름 변경 — 비우면 키를 지워 기본 표시로 되돌린다(그 경로도 되돌릴 수 있어야 한다)</summary>
public void RenameGroup(string groupId, string? name)
{
var trimmed = (name ?? string.Empty).Trim();
var current = document.GroupNames.TryGetValue(groupId, out var existing) ? existing : string.Empty;
var next = trimmed == LayerGroupViewModel.FallbackName ? string.Empty : trimmed;
if (string.Equals(current, next, StringComparison.Ordinal))
{
return;
}
Undo.Snapshot();
if (next.Length == 0)
{
document.GroupNames.Remove(groupId);
return;
}
document.GroupNames[groupId] = next;
}
/// <summary>그룹 펼침 상태 — 기본은 펼침</summary>
public bool IsGroupExpanded(string groupId) => !collapsedGroups.Contains(groupId);
/// <summary>그룹 펼침/접기 — 목록을 다시 만든다</summary>
public void SetGroupExpanded(string groupId, bool expanded)
{
if (expanded ? collapsedGroups.Remove(groupId) : collapsedGroups.Add(groupId))
{
RequestLayerRebuild();
}
}
/// <summary>
/// 목록 재구성 예약 — 펼침 토글은 <b>목록 안의 버튼</b>이 일으킨다. 즉시 재구성하면 클릭 이벤트가
/// 끝나기도 전에 그 버튼이 목록에서 떨어져 나가므로 한 틱 미뤄 안전하게 갈아 끼운다.
/// (진단 모드처럼 Application 이 없으면 그냥 즉시 재구성한다)
/// </summary>
public void RequestLayerRebuild()
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null)
{
RebuildLayerRows();
return;
}
dispatcher.BeginInvoke(new Action(RebuildLayerRows));
}
/// <summary>현재 문서에 살아 있는 그룹 식별자</summary>
public HashSet<string> ActiveGroupIds()
{
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id)
{
ids.Add(id);
}
}
return ids;
}
/// <summary>
/// 그룹 자가치유 — 멤버가 1개 이하로 줄어든 그룹을 풀고, 주인 없는 이름을 지운다.
/// 삭제·Undo·페이지 이동 뒤에 홀로 남은 그룹은 클릭 확장이 무의미하고 폴더만 어지럽힌다.
/// 편집 의도가 아니라 정리라서 Undo 스냅샷을 찍지 않는다.
/// </summary>
public void CleanupGroups()
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id)
{
counts[id] = counts.TryGetValue(id, out var n) ? n + 1 : 1;
}
}
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id && counts[id] < 2)
{
control.Model.GroupId = null;
}
}
foreach (var orphan in document.GroupNames.Keys
.Where(id => !counts.TryGetValue(id, out var n) || n < 2).ToList())
{
document.GroupNames.Remove(orphan);
}
collapsedGroups.RemoveWhere(id => !counts.ContainsKey(id));
}
/// <summary>타입 필터 — 비어 있으면 전체 표시</summary>
public IReadOnlyCollection<string> LayerTypeFilter => layerTypeFilter;
/// <summary>타입 필터 교체</summary>
public void SetLayerTypeFilter(IEnumerable<string> types)
{
layerTypeFilter.Clear();
foreach (var type in types)
{
layerTypeFilter.Add(type);
}
OnPropertyChanged(nameof(HasLayerTypeFilter));
OnPropertyChanged(nameof(LayerTypeFilterTip));
RebuildLayerRows();
}
/// <summary>문서에 실제로 존재하는 타입 목록(필터 후보) — 개수 많은 순</summary>
public List<(string Type, int Count)> LayerTypeCandidates()
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var control in AllControls())
{
counts[control.Type] = counts.TryGetValue(control.Type, out var n) ? n + 1 : 1;
}
return counts.OrderByDescending(p => p.Value).ThenBy(p => p.Key, StringComparer.Ordinal)
.Select(p => (p.Key, p.Value)).ToList();
}
/// <summary>
/// 레이어 목록 재구성 — <b>문서의 전 페이지</b>를 한 아웃라인으로 평탄화한다.
/// 페이지 헤더 → (펼쳐져 있으면) 그룹 폴더·컨트롤·컨테이너 자식 → 다음 페이지 → 맨 끝에 '페이지 추가'.
///
/// 순서는 <b>z-order 최상위가 맨 위</b>다(Figma·[200] 규약). PageViewModel.Controls 는 이미
/// 그리기 순서(마지막이 최상위)라 역순으로 훑는다 — 캔버스에서 위에 겹쳐 보이는 컨트롤을
/// 목록에서도 위에서 찾게 된다.
/// </summary>
public void RebuildLayerRows()
{
CleanupGroups();
IsRebuildingLayers = true;
LayerRows.Clear();
PageRows.Clear();
foreach (var page in Pages)
{
var header = new LayerPageViewModel(this, page);
PageRows.Add(header);
// 한 장뿐이면 트리에 페이지 마디를 두지 않는다 — 모든 컨트롤이 그 페이지 소속이라 정보가 0 이고,
// 최상위가 들여쓰기 없이 바로 보인다. 두 장 이상일 때만 경계 구분자로 남긴다.
if (ShowPageNodes)
{
LayerRows.Add(header);
}
if (!header.IsExpanded)
{
continue;
}
var rendered = new HashSet<string>(StringComparer.Ordinal);
foreach (var control in page.Controls.Reverse())
{
if (control.Model.GroupId is { } groupId)
{
if (!rendered.Add(groupId))
{
continue;
}
var members = page.Controls.Reverse().Where(c => c.Model.GroupId == groupId).ToList();
var shown = members.Where(PassesLayerFilter).ToList();
if (shown.Count == 0)
{
continue;
}
var group = new LayerGroupViewModel(this, groupId, members);
LayerRows.Add(group);
if (group.IsExpanded)
{
foreach (var member in shown)
{
AddLayerRow(member, GroupMemberIndent);
}
}
continue;
}
if (PassesLayerFilter(control))
{
AddLayerRow(control, PageChildIndent);
}
}
}
IsRebuildingLayers = false;
UpdateLayerHighlight();
OnPropertyChanged(nameof(IsLayerListEmpty));
OnPropertyChanged(nameof(LayerEmptyText));
LayerRowsRebuilt?.Invoke();
}
/// <summary>페이지 직속 컨트롤·그룹 폴더의 들여쓰기 — 페이지 노드 아래라 항상 한 칸 들어간다</summary>
private double PageChildIndent => ShowPageNodes ? 12 : 0;
/// <summary>그룹 멤버 들여쓰기 — 페이지 직속보다 한 단계 더</summary>
private double GroupMemberIndent => PageChildIndent + 20;
/// <summary>컨테이너(패널/그룹박스) 자식의 추가 들여쓰기</summary>
private const double ContainerChildIndent = 16;
/// <summary>
/// 선택 강조 3상태 + 연속 블록 병합 계산 — 목록을 다시 만들지 않고 <b>색만</b> 갱신한다.
///
/// 선택이 바뀔 때마다 평탄화를 다시 돌리면 1,067행 서식에서 클릭 한 번에 목록 전체가 재생성된다.
/// 그래서 강조는 별도 경로로 분리했다.
///
/// 그룹 집계는 <b>전 페이지</b>를 대상으로 한다 — 그룹 식별자는 페이지를 넘나들 수 있고,
/// 폴더가 파랑이 되려면 "그 그룹의 모든 멤버"가 선택돼 있어야 한다.
/// </summary>
public void UpdateLayerHighlight()
{
var total = new Dictionary<string, int>(StringComparer.Ordinal);
var selected = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var control in AllControls())
{
if (control.Model.GroupId is not { } id)
{
continue;
}
total[id] = total.TryGetValue(id, out var t) ? t + 1 : 1;
if (control.IsSelected)
{
selected[id] = selected.TryGetValue(id, out var s) ? s + 1 : 1;
}
}
bool GroupFullySelected(string id)
=> total.TryGetValue(id, out var t) && t > 0
&& selected.TryGetValue(id, out var s) && s == t;
var states = new LayerHighlight[LayerRows.Count];
for (var i = 0; i < LayerRows.Count; i++)
{
states[i] = LayerRows[i] switch
{
// 그룹 전체가 선택되면 파랑은 폴더가 가져가고 멤버는 서브트리 배경으로 물러난다
ControlViewModel c when c.Model.GroupId is { } gid && GroupFullySelected(gid) => LayerHighlight.Navy,
ControlViewModel c when c.IsSelected => LayerHighlight.Blue,
LayerGroupViewModel g when GroupFullySelected(g.GroupId) => LayerHighlight.Blue,
_ => LayerHighlight.None,
};
}
for (var i = 0; i < LayerRows.Count; i++)
{
if (LayerRows[i] is not ILayerRow row)
{
continue;
}
// 병합 판정은 '색'이 아니라 '강조냐 아니냐'다 — 그래야 파랑 폴더와 네이비 멤버가
// 색만 다른 하나의 이어진 블록으로 보인다
var top = i > 0 && states[i] != LayerHighlight.None && states[i - 1] != LayerHighlight.None;
var bottom = i < LayerRows.Count - 1
&& states[i] != LayerHighlight.None && states[i + 1] != LayerHighlight.None;
row.ApplyHighlight(states[i], top, bottom);
}
}
/// <summary>
/// 목록 재구성 중 — 이때 ListBox 가 쏟아내는 선택 해제는 사용자의 조작이 아니다.
/// (<c>LayerRows.Clear()</c> 가 SelectedItems 를 비우면서 SelectionChanged 를 발화한다)
/// </summary>
public bool IsRebuildingLayers { get; private set; }
/// <summary>레이어 목록 재구성 완료 — 목록 강조를 되살리라는 신호</summary>
public event Action? LayerRowsRebuilt;
/// <summary>컨트롤 행 + (펼쳐져 있으면) 컨테이너 자식 행을 재귀로 추가</summary>
private void AddLayerRow(ControlViewModel viewModel, double indent)
{
viewModel.LayerIndent = indent;
viewModel.LayerExpandedChanged ??= RequestLayerRebuild;
// 눈·자물쇠도 편집이다 — 실수로 서식 절반을 잠갔을 때 Ctrl+Z 로 돌아올 수 있어야 한다.
// 연속 토글은 400ms 안에서 한 스텝으로 묶는다(한 줄씩 잠그다 보면 Undo 스택이 금방 찬다).
viewModel.LayerFlagChanging ??= () => Undo.SnapshotForNudge();
LayerRows.Add(viewModel);
if (viewModel is not ContainerViewModel container || !viewModel.IsLayerExpanded)
{
return;
}
// 자식도 z-order 최상위가 위로 오게 역순
foreach (var child in container.Children.Reverse().Where(PassesLayerFilter))
{
AddLayerRow(child, indent + ContainerChildIndent);
}
}
/// <summary>
/// 검색어·타입 필터 통과 판정 — 자손이 걸리면 조상도 통과시킨다.
/// 그러지 않으면 패널 안의 컨트롤을 검색했을 때 부모가 빠져 자식이 통째로 사라진다.
/// </summary>
private bool PassesLayerFilter(ControlViewModel viewModel)
{
if (Matches(viewModel))
{
return true;
}
return viewModel is ContainerViewModel container && container.Children.Any(PassesLayerFilter);
bool Matches(ControlViewModel vm)
{
if (layerTypeFilter.Count > 0 && !layerTypeFilter.Contains(vm.Type))
{
return false;
}
return layerFilter.Length == 0
|| vm.LayerName.Contains(layerFilter, StringComparison.OrdinalIgnoreCase)
|| vm.Id.Contains(layerFilter, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>선택 집합이 바뀌었을 때 — 강조 3상태와 그룹 폴더의 눈·자물쇠 표시를 다시 계산</summary>
private void LayerSelectionChanged()
{
UpdateLayerHighlight();
foreach (var group in LayerRows.OfType<LayerGroupViewModel>())
{
group.NotifyFlagsChanged();
}
}
/// <summary>레이어 행 → 선택 대상 컨트롤 목록. 페이지 행·추가 행은 선택 대상이 아니라 빈 목록</summary>
public static IEnumerable<ControlViewModel> ControlsOfLayerRow(object? row) => row switch
{
ControlViewModel control => new[] { control },
LayerGroupViewModel group => group.Members,
_ => Enumerable.Empty<ControlViewModel>(),
};
/// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</summary>
public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page);
/// <summary>페이지 펼침/접기</summary>
public void SetPageExpanded(PageViewModel page, bool expanded)
{
if (expanded ? collapsedPages.Remove(page) : collapsedPages.Add(page))
{
RequestLayerRebuild();
}
}
/// <summary>
/// 페이지 헤더 행 클릭 — 활성 페이지로 삼고, 캔버스를 그 페이지로 옮기고, 속성 패널을 페이지로 돌린다.
///
/// 페이지도 레거시에서는 하나의 <c>Object</c>(MDesignerHost)라 자기 속성을 갖는다.
/// 컨트롤을 하나도 고르지 않은 상태에서만 페이지 속성을 보여주므로 선택은 비운다.
/// </summary>
public void ActivatePage(PageViewModel page)
{
if (!ReferenceEquals(SelectedPage, page))
{
SelectedPage = page;
}
Selection.Clear();
InspectedPage = page;
foreach (var header in PageRows)
{
header.NotifyStateChanged();
}
ScrollToPageRequested?.Invoke(page);
}
/// <summary>
/// 속성 패널이 들여다보는 페이지 — 컨트롤 선택이 없을 때만 유효하다.
/// 컨트롤을 고르는 순간 <see cref="SelectionService.SetChanged"/> 경로에서 비워진다.
/// </summary>
public PageViewModel? InspectedPage
{
get => inspectedPage;
private set
{
if (!ReferenceEquals(inspectedPage, value))
{
inspectedPage = value;
Inspector.Rebuild();
}
}
}
/// <summary>선택이 생기면 페이지 속성 모드를 벗어난다</summary>
private void ClearInspectedPageOnSelection()
{
if (inspectedPage is not null && Selection.Items.Count > 0)
{
inspectedPage = null;
}
}
/// <summary>캔버스를 특정 페이지로 스크롤하라는 요청 — 뷰가 구독한다</summary>
public event Action<PageViewModel>? ScrollToPageRequested;
/// <summary>잘라내기 — 복사 후 삭제</summary>
public void CutSelection()
{
if (Selection.Items.Count == 0)
{
return;
}
CopySelection();
DeleteSelection();
}
/// <summary>같은 크기로 — 기준(Primary) 컨트롤의 너비/높이에 맞춤 (레거시 TK_SizeToControl 계열)</summary>
public void SizeToControl(string mode)
{
var primary = Selection.Primary;
if (primary is null || Selection.Items.Count < 2)
{
return;
}
Undo.Snapshot();
foreach (var vm in Selection.Items.Where(v => v != primary))
{
if (mode is "Both" or "Width")
{
vm.Width = primary.Width;
}
if (mode is "Both" or "Height")
{
vm.Height = primary.Height;
}
}
Selection.NotifyBoundsChanged();
}
/// <summary>
/// 간격 조절 — 가로/세로 균등·넓게·좁게·붙이기 (레거시 TK_Horiz/VertSpace 계열).
/// 축 정렬 순서로 첫 컨트롤 고정, 이후 컨트롤 이동. 넓게/좁게 단위는 그리드 크기.
/// </summary>
/// <summary>
/// 선택한 것들 사이 간격을 <b>정확한 픽셀 값으로</b> 놓는다.
///
/// <b>왜 필요한가.</b> 여기까지 간격을 조절하는 길은 4px 씩 넓게/좁게뿐이었다.
/// 20px 를 32px 로 바꾸려면 3번, 로고 아래 4단 메뉴로 하면 <b>12왕복</b>이었다.
/// 단축키(Alt+[ / Alt+])를 붙여 왕복은 없앴지만 여전히 4px 씩이다.
/// 드래그 스냅은 형제 간격을 정확히 계산해 분홍 표식까지 그리는데(SnapSolver),
/// 명령 경로에는 그 값을 <b>넣을 칸</b>이 없었다.
///
/// 첫 항목은 움직이지 않는다 — 기준이 흔들리면 같은 값을 두 번 넣어도 자리가 계속 밀린다.
/// </summary>
public void SetExactSpacing(double gap, bool horizontal)
{
if (Selection.Items.Count < 2 || !double.IsFinite(gap) || gap < 0)
{
return;
}
var ordered = Selection.Items
.Select(vm => (Vm: vm, World: WorldBoundsOf(vm), Offset: ParentWorldOffset(vm)))
.OrderBy(x => horizontal ? x.World.X : x.World.Y)
.ToList();
Undo.Snapshot();
var cursor = horizontal ? ordered[0].World.Right : ordered[0].World.Bottom;
for (var i = 1; i < ordered.Count; i++)
{
var (vm, world, offset) = ordered[i];
if (horizontal)
{
vm.X = Math.Round(cursor + gap - offset.X);
cursor += gap + world.Width;
continue;
}
vm.Y = Math.Round(cursor + gap - offset.Y);
cursor += gap + world.Height;
}
Selection.NotifyBoundsChanged();
}
/// <summary>
/// 지금 선택의 간격 — 숫자 칸에 무엇을 보여 줄지.
/// 값이 서로 다르면 null(빈 칸) — 하나로 보여 주면 그 값이 이미 적용된 것처럼 읽힌다.
/// </summary>
public double? CurrentSpacing(bool horizontal)
{
if (Selection.Items.Count < 2)
{
return null;
}
var ordered = Selection.Items
.Select(vm => WorldBoundsOf(vm))
.OrderBy(r => horizontal ? r.X : r.Y)
.ToList();
var gaps = new List<double>();
for (var i = 1; i < ordered.Count; i++)
{
gaps.Add(Math.Round(horizontal
? ordered[i].X - ordered[i - 1].Right
: ordered[i].Y - ordered[i - 1].Bottom));
}
return gaps.Distinct().Count() == 1 ? gaps[0] : null;
}
public void AdjustSpacing(string mode)
{
var isHorizontal = mode.StartsWith("Horiz", StringComparison.Ordinal);
var minCount = mode.EndsWith("Equal", StringComparison.Ordinal) ? 3 : 2;
if (Selection.Items.Count < minCount)
{
return;
}
// 월드 좌표 기준 정렬(정렬 명령과 동일 정책)
var ordered = Selection.Items
.Select(vm => (Vm: vm, World: WorldBoundsOf(vm), Offset: ParentWorldOffset(vm)))
.OrderBy(x => isHorizontal ? x.World.X : x.World.Y)
.ToList();
Undo.Snapshot();
double PosOf(int i) => isHorizontal ? ordered[i].World.X : ordered[i].World.Y;
double SizeOf(int i) => isHorizontal ? ordered[i].World.Width : ordered[i].World.Height;
// 목표 시작 좌표 계산(월드)
var targets = new double[ordered.Count];
targets[0] = PosOf(0);
if (mode.EndsWith("Equal", StringComparison.Ordinal))
{
var span = ordered.Max(x => (isHorizontal ? x.World.Right : x.World.Bottom)) - PosOf(0);
var sizeSum = ordered.Sum(x => isHorizontal ? x.World.Width : x.World.Height);
var gap = Math.Max(0, (span - sizeSum) / (ordered.Count - 1));
for (var i = 1; i < ordered.Count; i++)
{
targets[i] = targets[i - 1] + SizeOf(i - 1) + gap;
}
}
else if (mode.EndsWith("Concat", StringComparison.Ordinal))
{
for (var i = 1; i < ordered.Count; i++)
{
targets[i] = targets[i - 1] + SizeOf(i - 1);
}
}
else
{
var delta = mode.EndsWith("Increase", StringComparison.Ordinal) ? Snap.GridSize : -Snap.GridSize;
for (var i = 1; i < ordered.Count; i++)
{
targets[i] = PosOf(i) + delta * i;
}
}
for (var i = 1; i < ordered.Count; i++)
{
var (vm, _, offset) = ordered[i];
if (isHorizontal)
{
vm.X = Math.Round(targets[i] - offset.X);
}
else
{
vm.Y = Math.Round(targets[i] - offset.Y);
}
}
Selection.NotifyBoundsChanged();
}
/// <summary>페이지 가운데 맞춤 — 선택 묶음을 페이지 가로/세로 중앙으로(상대 배치 유지, 레거시 TK_Center 계열)</summary>
public void CenterInPage(string axis)
{
if (Selection.Items.Count == 0)
{
return;
}
var bbox = SelectionWorldBounds();
var page = Selection.Primary is not null ? PageOf(Selection.Primary) : null;
if (bbox is null || page is null)
{
return;
}
Undo.Snapshot();
var dx = axis == "H" ? Math.Round((page.WidthDip - bbox.Value.Width) / 2 - bbox.Value.X) : 0;
var dy = axis == "V" ? Math.Round(page.OffsetY + (page.HeightDip - bbox.Value.Height) / 2 - bbox.Value.Y) : 0;
foreach (var vm in Selection.Items)
{
vm.X += dx;
vm.Y += dy;
}
Selection.NotifyBoundsChanged();
}
/// <summary>드래그 종료 후 소속 페이지 재배정 — 컨트롤 중심의 월드 Y 기준</summary>
public void ReassignPagesAfterMove(List<ControlViewModel> moved)
{
// 컨테이너 자식은 컨테이너를 따라가므로 최상위만 재배정
foreach (var vm in moved.Where(v => v.Parent is null))
{
var currentPage = PageOf(vm);
if (currentPage is null)
{
continue;
}
var worldCenterY = currentPage.OffsetY + vm.Y + vm.Height / 2;
var targetPage = PageAt(worldCenterY);
if (targetPage is null || targetPage == currentPage)
{
continue;
}
// 페이지 로컬 Y 재계산 후 모델/VM 이동(최상위로)
vm.Y = Math.Round(currentPage.OffsetY + vm.Y - targetPage.OffsetY);
currentPage.Controls.Remove(vm);
currentPage.Model.Controls.Remove(vm.Model);
targetPage.Controls.Add(vm);
targetPage.Model.Controls.Insert(0, vm.Model);
}
RefreshOverlay();
}
private ControlViewModel CreateAndAttach(ControlElement element, PageViewModel page, bool attachToEnd)
{
var rootFontText = page.Model.Root.Props.GetText("Font");
var rootFont = rootFontText is not null
? LegacyFormat.ParseFont(rootFontText)
: new LegacyFont();
var viewModel = DocumentMapper.CreateControl(element, rootFont, System.Windows.Media.Brushes.Black);
if (attachToEnd)
{
page.Controls.Add(viewModel);
}
else
{
page.Controls.Insert(0, viewModel);
}
return viewModel;
}
private static void AssignNewIds(ControlElement element, HashSet<string> usedIds)
{
// 붙여넣기 클론의 Id 재발급(트리 전체) — Name Property 는 쓰기 시 동기화됨
element.Id = IdGenerator.NextId(usedIds, element.Type == LegacyTypeCatalog.PlaceholderType
? "Control"
: element.Type);
foreach (var child in element.Children)
{
AssignNewIds(child, usedIds);
}
}
#endregion
}