사용성 감사를 돌렸더니 결론이 뜻밖이었다. <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>
2827 lines
112 KiB
C#
2827 lines
112 KiB
C#
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
|
||
}
|