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;
/// 폰트 사용 집계 1조합 — (타입, 글꼴, 크기, 스타일)별 컨트롤 목록
public sealed class FontUsageGroup
{
/// 중립 타입명
public string Type { get; }
/// 글꼴 패밀리
public string Family { get; }
/// 크기(pt)
public double SizePt { get; }
/// 굵게
public bool Bold { get; }
/// 기울임
public bool Italic { get; }
/// 밑줄
public bool Underline { get; }
/// Font 미지정(부모 상속) 그룹 여부
public bool IsInherited { get; }
/// 해당 조합의 컨트롤들
public List Controls { get; } = new();
/// 목록 표시 텍스트
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;
}
}
///
/// 서식 문서 1개의 편집 허브 — 페이지 VM 트리·선택·스냅·Undo·상호작용·오버레이를 소유.
/// 모델(FormDocument)이 진실이고 VM 은 투영: Undo 복원 = 문서 교체 + RebuildFromDocument.
///
public sealed class DesignerViewModel : ViewModelBase
{
#region Member Fields
private const double PageGap = 28;
private const double WorldPad = 24;
private static readonly List clipboard = new();
/// 한 계단에 몇 번 내려간 뒤 옆으로 비키는가 — 종이를 벗어나기 전에 접는다
private const int PlacementLaneSteps = 12;
/// 팔레트 즉시 배치 계단 — 지우고 다시 놓아도 같은 자리로 돌아가지 않게 단조 증가한다
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 tabOrderSequence = new();
private readonly Dictionary? spreadDesigns;
/// 정렬을 페이지에 맞출 것인가 — 인스펙터 토글이 세운다
private bool alignToPage;
// 레이어 목록 상태 — 접힌 그룹과 타입 필터는 문서가 아니라 편집 세션에 속한다(Undo 대상 아님)
private readonly HashSet collapsedGroups = new(StringComparer.Ordinal);
private readonly HashSet collapsedPages = new();
private PageViewModel? inspectedPage;
private readonly HashSet layerTypeFilter = new(StringComparer.Ordinal);
private readonly List watchedControls = new();
#endregion
#region Properties
/// 편집 중인 문서(진실 원본)
public FormDocument Document => document;
/// 파일 출처 경로(.xml/.json) — DB/신규 문서는 null
public string? FilePath { get; set; }
/// DB 출처 여부
public bool IsFromDb { get; set; }
/// 이력 버전 열람 탭 여부 — 열람한 과거 SdgKey (활성본이면 null)
public decimal? HistorySdgKey { get; set; }
/// 탭 헤더 표시명 — [코드] 제목 (출처)
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 : "새 서식";
}
}
/// 탭 헤더 갱신 통지(저장/개명 후)
public void NotifyDisplayNameChanged() => OnPropertyChanged(nameof(DisplayName));
/// 페이지 VM 목록
public ObservableCollection Pages { get; } = new();
/// 선택 서비스
public SelectionService Selection { get; }
/// 스냅 엔진
public SnapEngine Snap { get; }
/// Undo/Redo 서비스
public UndoService Undo { get; }
/// 마우스 상호작용 상태머신
public InteractionController Interaction { get; }
/// 선택/마퀴/가이드 오버레이 상태
public SelectionOverlayViewModel Overlay { get; } = new();
/// 속성 인스펙터
public InspectorViewModel Inspector { get; }
/// 페이지 패널에서 선택된 페이지
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();
}
}
}
}
///
/// 문서 전 페이지의 최상위 컬렉션을 구독해 컨트롤 추가·삭제·순서 변경을 레이어 목록에 반영한다.
/// (예전에는 page.Controls 를 그대로 ItemsSource 로 줘서 공짜로 따라왔지만,
/// 아웃라인이 평탄화 결과가 된 뒤로는 원본이 변할 때 다시 만들어야 한다.)
///
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();
///
/// 레이어 검색어 — 이름(LayerName)·Id 부분일치. 서식당 컨트롤이 수백 개인 경우가 흔해
/// (운영 최대 1,067개) 스크롤만으로는 대상을 찾을 수 없다.
///
public string LayerFilter
{
get => layerFilter;
set
{
if (SetProperty(ref layerFilter, value ?? string.Empty))
{
RebuildLayerRows();
}
}
}
///
/// 레이어 목록에 실제로 표시할 행 — 그룹 폴더 행( )과
/// 컨트롤 행( )이 섞인 평탄화 결과 다.
///
/// TreeView 를 쓰지 않는 이유: 목록이 최대 1,067행이라 가상화가 필요하고, 컨트롤 행 템플릿
/// (눈·자물쇠·2줄 이름)을 이미 ListBox 기준으로 만들어 뒀다. 계층은 LayerIndent 여백으로만 표현한다
/// (인스펙터 섹션이 쓰는 방식과 같다).
///
public ObservableCollection LayerRows { get; } = new();
/// 페이지 구획 목록 — 트리와 별도. 페이지 조작(추가·복제·삭제·이동)은 전부 이쪽이다
public ObservableCollection PageRows { get; } = new();
/// 트리에 페이지 마디를 둘 것인가 — 한 장짜리 문서에서는 뜻이 없다
public bool ShowPageNodes => Pages.Count > 1;
///
/// 컨트롤 행이 하나도 없는지 — 빈 목록에 안내를 띄우기 위한 플래그.
/// 페이지 헤더와 '페이지 추가' 행은 늘 있으므로 행 수가 아니라 컨트롤 행 유무로 판단한다.
///
public bool IsLayerListEmpty => !LayerRows.OfType().Any();
/// 레이어 목록이 비어 보이는 이유 — 걸러진 것인지, 애초에 없는 것인지
public string LayerEmptyText =>
Pages.All(p => p.Controls.Count == 0)
? "아직 컨트롤이 없습니다"
: "검색·필터에 맞는 항목이 없습니다";
/// 타입 필터가 걸려 있는지 — 헤더 버튼 강조/초기화 버튼 활성 판단
public bool HasLayerTypeFilter => layerTypeFilter.Count > 0;
/// 타입 필터 상태 툴팁
public string LayerTypeFilterTip =>
layerTypeFilter.Count == 0 ? "타입으로 거르기" : $"{layerTypeFilter.Count}종만 표시 중";
/// 줌 배율(0.25~3.0)
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;
}
}
}
/// 줌 표시 텍스트
public string ZoomPercentText => $"{zoom * 100:0}%";
/// 월드 논리 너비
public double WorldWidth => Pages.Count == 0 ? 720 : Pages.Max(p => p.WidthDip);
/// 월드 논리 높이
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? 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);
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
/// 실행 취소
public ICustomCommand? UndoCommand { get; set; }
/// 다시 실행
public ICustomCommand? RedoCommand { get; set; }
/// 선택 삭제
public ICustomCommand? DeleteCommand { get; set; }
/// 복사
public ICustomCommand? CopyCommand { get; set; }
/// 붙여넣기
public ICustomCommand? PasteCommand { get; set; }
/// 복제(Ctrl+D)
public ICustomCommand? DuplicateCommand { get; set; }
/// 활성 페이지 전체 선택
public ICustomCommand? SelectAllCommand { get; set; }
/// 맨 앞으로
public ICustomCommand? BringToFrontCommand { get; set; }
/// 맨 뒤로
public ICustomCommand? SendToBackCommand { get; set; }
/// 정렬(파라미터: Left/Right/Top/Bottom/CenterH/CenterV)
public ICustomCommand? AlignCommand { get; set; }
/// 페이지 추가
public ICustomCommand? AddPageCommand { get; set; }
/// 선택 페이지 삭제
public ICustomCommand? RemovePageCommand { get; set; }
/// 그룹(Ctrl+G) — 선택을 한 단위로 묶음(편집 전용, 저장 안 함)
public ICustomCommand? GroupCommand { get; set; }
/// 그룹 해제(Ctrl+Shift+G)
public ICustomCommand? UngroupCommand { get; set; }
/// 잘라내기(Ctrl+X)
public ICustomCommand? CutCommand { get; set; }
/// 같은 크기로(파라미터: Both/Width/Height — 기준 컨트롤에 맞춤)
public ICustomCommand? SizeToControlCommand { get; set; }
/// 간격 조절(파라미터: HorizEqual/HorizIncrease/HorizDecrease/HorizConcat/VertEqual/...)
public ICustomCommand? SpacingCommand { get; set; }
/// 페이지 가운데 맞춤(파라미터: H/V)
public ICustomCommand? CenterInPageCommand { get; set; }
/// 탭순서 편집 모드 토글(시작/완료)
public ICustomCommand? TabOrderCommand { get; set; }
#endregion
#region Methods - 문서/줌
/// 모델 → VM 트리 재구성 — 열기/Undo 복원 시 호출
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())
{
if (spreadDesigns.TryGetValue(spread.Id, out var grid))
{
spread.GridInfo = grid;
}
}
}
OnPropertyChanged(nameof(WorldWidth));
OnPropertyChanged(nameof(WorldHeight));
RefreshOverlay();
Inspector.Rebuild();
// 페이지 인스턴스가 통째로 새로 만들어졌다 — 구독과 접힘 상태를 갈아끼우고 목록을 다시 만든다
collapsedPages.Clear();
WatchLayerSource();
RebuildLayerRows();
}
///
/// 페이지 추가 — 문서 끝에 빈 페이지(720×856).
///
/// 루트 이름은 페이지 수가 아니라 사용 중인 이름 을 보고 정한다.
/// 페이지 수로 지으면 루트가 MDesignerHost1·MDesignerHost3 인 2장짜리 서식에서
/// 곧바로 MDesignerHost3 이 중복된다(레거시는 전 페이지를 훑어 최대번호+1 을 붙인다).
///
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();
}
///
/// 페이지 루트에 유일한 이름을 부여한다 — 이름과 Name 속성을 함께 맞춘다.
///
/// 루트 이름이 겹치면 제자리 갱신 저장이 깨진다: E_SctMst 동기화는 SctObjNam 사전이라
/// 둘째 루트가 첫째 루트 행에 매핑되고, 그 페이지 자식들의 SctParObj 가 존재하지 않는
/// ObjID 를 가리킨 채 남는다(화면은 뜨지만 항목 계층이 깨진다).
///
private static void RenamePageRoot(FormPage page, HashSet usedIds)
{
page.Root.Id = IdGenerator.NextId(usedIds, LegacyTypeCatalog.HostClassName);
if (page.Root.Props.Contains("Name"))
{
page.Root.Props.SetText("Name", page.Root.Id);
}
}
///
/// 페이지 복제 — 용지 설정과 컨트롤을 통째로 베껴 바로 뒤에 끼운다.
///
/// 컨트롤 이름(Id)은 문서 안에서 유일해야 한다 — 액션 대상·입력 파라미터가 이름으로 서로를
/// 가리키고 CalcBox 수식도 이름으로 합산하므로, 같은 이름이 두 페이지에 있으면 런타임이 어느 쪽을
/// 잡을지 알 수 없다. 그래서 붙여넣기와 같은 경로로 전부 새 이름을 발급한다.
/// 그룹도 복제본끼리 다시 묶는다(원본 그룹에 끼면 한쪽을 클릭할 때 반대편까지 딸려 온다).
///
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(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;
}
/// 페이지 삭제(확인 후) — 최소 1페이지 유지
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();
}
/// 선택 페이지 삭제(확인 후) — 최소 1페이지 유지
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();
}
/// 선택 페이지 즉시 삭제(확인 없음 — 스모크/내부용)
public void RemoveSelectedPageForSmoke()
{
if (SelectedPage is null || document.Pages.Count <= 1)
{
return;
}
Undo.Snapshot();
document.Pages.Remove(SelectedPage.Model);
RebuildFromDocument();
}
/// 선택 페이지 용지 크기 적용(px)
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));
}
///
/// 용지 크기가 바뀐 뒤 — 페이지 스택 오프셋과 월드 크기를 다시 계산한다.
/// 페이지가 여러 장이면 한 장의 높이 변화가 아래 페이지 전부를 밀어낸다.
///
public void NotifyPaperResized()
{
RecalcPageOffsets();
OnPropertyChanged(nameof(WorldWidth));
OnPropertyChanged(nameof(WorldHeight));
RefreshOverlay();
}
///
/// 페이지 기본 글꼴이 바뀐 뒤 — 그 페이지 컨트롤의 상속 컨텍스트를 다시 흘린다.
/// Font 를 명시하지 않은 컨트롤은 루트 글꼴을 물려받으므로 값 하나가 화면 전체를 바꾼다.
///
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();
}
/// 문서 교체 후 재구성 — Undo 복원 경로
public void ReplaceDocument(FormDocument newDocument)
{
document = newDocument;
RebuildFromDocument();
}
/// 페이지 세로 오프셋 재계산
public void RecalcPageOffsets()
{
double y = 0;
foreach (var page in Pages)
{
page.OffsetY = y;
y += page.HeightDip + PageGap;
}
}
/// 줌 확대
public void ZoomIn() => Zoom = Math.Round(Zoom * 1.1, 2);
/// 줌 축소
public void ZoomOut() => Zoom = Math.Round(Zoom / 1.1, 2);
/// 줌 100%
public void ZoomReset() => Zoom = 1.0;
///
/// 캔버스 뷰포트 크기 통지 — DesignerCanvasView 가 SizeChanged 에서 호출한다.
/// ZoomFit 은 뷰포트를 알아야 하는데 VM 은 시각 트리를 모르므로 값만 받아 둔다.
///
public void ReportViewport(double width, double height)
{
viewportWidth = width;
viewportHeight = height;
}
///
/// 화면 맞춤 — 활성 페이지 한 장이 뷰포트에 들어오는 배율.
/// 문서 전체(WorldHeight)가 아니라 페이지 한 장 기준이다. 7페이지 서식을 통째로 맞추면
/// 14% 가 되어 아무것도 안 보인다(운영 최대 7페이지).
///
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 - 좌표/히트테스트
/// 최상위 조상(페이지 직속) — 자신이 최상위면 자신
private static ControlViewModel RootAncestorOf(ControlViewModel viewModel)
{
var current = viewModel;
while (current.Parent is not null)
{
current = current.Parent;
}
return current;
}
/// VM 이 속한 페이지(컨테이너 자식 포함)
public PageViewModel? PageOf(ControlViewModel viewModel)
{
var root = RootAncestorOf(viewModel);
return Pages.FirstOrDefault(p => p.Controls.Contains(root));
}
/// 부모 체인의 월드 오프셋(페이지 OffsetY 포함) — 월드 = 오프셋 + (X,Y)
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);
}
/// 월드 Y 로 페이지 판정 — 페이지 사이 간격은 가까운 쪽
public PageViewModel? PageAt(double worldY)
{
foreach (var page in Pages)
{
if (worldY <= page.OffsetY + page.HeightDip + PageGap / 2)
{
return page;
}
}
return Pages.LastOrDefault();
}
/// 컨트롤의 월드 bbox(컨테이너 자식은 부모 체인 오프셋 반영)
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));
}
/// 선택 전체의 월드 bbox — 선택 없음이면 null
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;
}
/// 월드 좌표 최상위 컨트롤 히트 — 컨테이너는 자식 우선(재귀), 잠금/숨김 제외
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);
}
private static ControlViewModel? HitInList(IList 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;
}
/// 월드 rect 와 교차하는 컨트롤(마퀴 선택)
public List ControlsIntersecting(Rect worldRect)
{
var result = new List();
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;
}
/// 오버레이(선택 박스/핸들) 재계산
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
/// 인라인 텍스트 편집 요청 — 캔버스 뷰(EditorLayer)가 구독
public event Action? InlineEditRequested;
/// 캔버스를 특정 컨트롤로 스크롤 요청 — 캔버스 뷰가 구독(뷰포트를 VM 이 모르므로 위임)
public event Action? ScrollToRequested;
#endregion
#region Methods - 컨트롤로 이동
///
/// 레이어 목록에서 고른 컨트롤을 선택하고 화면을 그리로 옮긴다.
///
/// 컨트롤이 수백 개인 서식(운영 최대 1,067개)에서는 목록에서 이름을 찾아도 그게 종이 어디에
/// 있는지 알 수 없었다. 다중 페이지 서식이면 다른 페이지에 있을 수도 있어 더 그렇다.
/// 스크롤은 뷰포트 크기를 알아야 하므로 캔버스 뷰에 위임한다.
///
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 - 인라인 텍스트 편집
/// 더블클릭 인라인 편집 가능한 타입인지 — 레지스트리에 Text 속성 정의가 있거나 데이터소스(쿼리 편집)
public static bool IsTextEditable(ControlViewModel viewModel)
=> viewModel is DataTableViewModel
|| ControlRegistry.Find(viewModel.Type)?.Properties.Any(p => p.Key == "Text") == true;
/// 인라인 편집 요청 발신 — InteractionController 의 더블클릭 경로(뷰가 대상 타입별 편집기 분기)
public void RequestInlineEdit(ControlViewModel viewModel)
{
if (!IsTextEditable(viewModel))
{
return;
}
Selection.SetSingle(viewModel);
InlineEditRequested?.Invoke(viewModel);
}
/// 인라인 편집 커밋(Text) — 변경 시에만 Undo 1스텝 + 시각 재해석
public void CommitInlineText(ControlViewModel viewModel, string newText)
=> CommitPropertyText(viewModel, "Text", newText);
/// 속성 텍스트 커밋 공통 — 변경 시에만 Undo 1스텝 + 시각 재해석 + 인스펙터 갱신
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 - 탭순서 편집
/// 탭순서 대상 타입(입력 컨트롤 — 레거시 제외 규칙: Label/MDataTable 등 비입력 제외)
private static readonly HashSet TabOrderTypes = new(StringComparer.Ordinal)
{
"TextBox", "MaskedTextBox", "CheckBox", "RadioButton", "ComboBox",
"ListBox", "CheckList", "DateTimePicker", "CalcBox", "Button",
};
/// 탭순서 편집 모드 여부 — 모드 중 클릭은 순번 지정으로 동작
public bool IsTabOrderMode
{
get => isTabOrderMode;
private set
{
if (SetProperty(ref isTabOrderMode, value))
{
OnPropertyChanged(nameof(TabOrderMenuHeader));
}
}
}
/// 탭순서 메뉴 표시 텍스트
public string TabOrderMenuHeader => IsTabOrderMode ? "탭순서 편집 완료(적용)" : "탭순서 편집 시작";
/// 모드 토글 — 시작: 배지 표시, 완료: 클릭 순서대로 TabIndex 적용(레거시 규약 0,100,200…)
public void ToggleTabOrderMode()
{
if (IsTabOrderMode)
{
ApplyTabOrder();
ExitTabOrderMode();
}
else
{
tabOrderSequence.Clear();
Selection.Clear();
IsTabOrderMode = true;
RefreshTabBadges();
}
}
/// 모드 취소(Esc) — 적용 없이 종료
public void CancelTabOrderMode() => ExitTabOrderMode();
/// 모드 중 컨트롤 클릭 — 순번 지정/해제 토글
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();
}
///
/// 탭순서 적용 — 초기화(999999+i) 후 클릭 순서 0,100,200…(레거시 TK_TABINDEXSAVE 이식).
///
/// 초기화 범위는 이번에 손댄 페이지 로 한정한다. 레거시는 페이지마다 별도 디자인
/// 서피스라 한 페이지를 편집해도 다른 페이지 TabIndex 는 그대로 남는데, 문서 전체를 밀면
/// 손대지 않은 페이지의 탭 순서가 통째로 999999+i 로 덮여 입력 순서가 무너진다.
/// 여러 페이지를 걸쳐 클릭했다면 그 페이지들이 모두 대상이 된다(한 페이지만 클릭한
/// 보통의 경우는 레거시와 동일하게 동작한다).
///
private void ApplyTabOrder()
{
if (tabOrderSequence.Count == 0)
{
return;
}
Undo.Snapshot();
var touched = new HashSet();
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 는 컨테이너 범위 다(Windows 표준 탐색). 문서 평면에 0,100,200… 을 뿌리면
// 패널 안의 것들이 저희끼리만 겨루게 되어 탭이 그 패널을 통째로 지나친 뒤에야 들어간다.
// 그래서 부모별로 따로 매기고, 패널 자신에게는 제 자식 중 가장 이른 순번을 준다.
// Dictionary 는 null 키를 받지 않는다 — 최상위는 따로 센다
var topRank = 0;
var byParent = new Dictionary();
var panelRank = new Dictionary();
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();
}
/// 대상 컨트롤 배지 갱신 — 지정된 것은 순번(파랑), 미지정은 "–"(회색)
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,
});
}
}
///
/// 문서의 모든 컨트롤 이름 — 쿼리의 《컨트롤명》 치환 후보.
/// 런타임도 같은 서식 안에서만 이름을 찾으므로(clsMDataTable 의 poAllControlOnSheet) 범위가 같다.
///
public IReadOnlyList ControlNames()
=> AllControls().Select(c => c.Id).Where(id => id.Length > 0).Distinct(StringComparer.Ordinal).ToList();
///
/// 한 페이지의 컨트롤 순회(컨테이너 자식 포함) — 스냅 후보 수집용.
/// 스냅은 앵커 페이지 안에서만 본다. 전 페이지를 넣으면 후보가 폭발하고,
/// 다른 종이의 좌표에 붙는 것은 뜻도 없다.
///
public IEnumerable ControlsOfPage(PageViewModel page)
{
foreach (var vm in page.Controls)
{
foreach (var descendant in Descend(vm))
{
yield return descendant;
}
}
}
/// 선택 항목과 그 자손 전부 — 끌려가는 컨테이너의 자식을 스냅 후보에서 빼는 데 쓴다
public HashSet SelectionWithDescendants()
{
var set = new HashSet();
foreach (var item in Selection.Items)
{
foreach (var vm in Descend(item))
{
set.Add(vm);
}
}
return set;
}
private static IEnumerable 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;
}
}
}
}
/// 문서 전체 컨트롤 순회(컨테이너 자식 포함, 모델 순서)
private IEnumerable AllControls()
{
IEnumerable 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 - 폰트 일괄 변경
/// 텍스트 렌더 대상 타입(폰트 집계/일괄 변경 대상 — 레거시 frmFontManager 대상군 확장)
private static readonly HashSet FontTargetTypes = new(StringComparer.Ordinal)
{
"Label", "TextBox", "MaskedTextBox", "CheckBox", "RadioButton", "ComboBox",
"ListBox", "CheckList", "DateTimePicker", "GroupBox", "CalcBox", "Button",
};
///
/// 문서 전체(컨테이너 자식 포함) 폰트 사용 집계 — (타입, 글꼴, 크기, 스타일) 조합별 컨트롤 목록.
/// Font 미지정(상속) 컨트롤은 유효 폰트 기준으로 집계된다.
///
public List CollectFontUsage()
{
var groups = new Dictionary(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();
}
/// 선택 조합의 컨트롤 전체에 새 폰트 일괄 적용 — Undo 1스텝
public int ApplyFontBulk(IEnumerable 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 - 편집
/// 팔레트 즉시 배치 — 활성 페이지 중앙 부근, 겹침 방지 오프셋(더블클릭/플라이아웃 공용)
public void AddPaletteItemAtCenter(string type)
{
var page = SelectedPage ?? Pages.FirstOrDefault();
if (page is null)
{
return;
}
// 겹침 방지 오프셋은 단조 증가하는 자체 카운터 로 센다.
//
// 전에는 (page.Controls.Count % 8) * 16 이었다. 두 가지가 잘못이다 —
// ① % 8 이라 9번째가 1번째와 정확히 같은 자리 에 떨어진다.
// ② 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));
}
/// 팔레트 드롭 — 월드 좌표에 신규 컨트롤 생성(모델 index 0 = 최상위)
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);
}
/// 페이지 로컬 좌표의 최상위 컨테이너(Panel/GroupBox) — 없으면 null
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;
}
/// 선택 삭제
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();
}
/// 선택 복사 — 내부 클립보드(딥클론, 모델 순서 유지)
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);
}
/// 붙여넣기 — 활성 페이지에 +12,+12 오프셋, 새 Id 부여 (표는 제외 — 참조)
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();
var usedIds = IdGenerator.CollectUsed(document);
// 그룹은 복제본끼리 다시 묶는다 — Clone 이 GroupId 를 그대로 복사하므로 그냥 두면
// 붙여넣은 컨트롤이 원본 그룹에 끼어들어 원본 하나만 클릭해도 복제본까지 함께 선택된다
var groupRemap = new Dictionary(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();
}
/// 복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다
private void RemapGroupId(ControlElement element, Dictionary 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);
}
}
///
/// 표(Spread) 타입 — E_SpdMst 격자를 서식코드+이름으로 조회하므로 이름 변경·복제가 금지된다.
/// VM 형이 아니라 모델 Type 문자열로 판정한다(클립보드가 ControlElement 이고 컨테이너 중첩도 잡아야 함).
///
public static bool IsSpread(ControlElement element)
=> string.Equals(element.Type, "Spread", StringComparison.Ordinal);
/// 자신 또는 자손에 표(Spread)가 있는지
public static bool ContainsSpread(ControlElement element)
=> IsSpread(element) || element.Children.Any(ContainsSpread);
///
/// 붙여넣기 가능 항목과 차단된 표 개수 — 알림과 분리해 무인 검증이 가능하도록 둔 순수 함수.
/// 표의 격자는 서식코드+컨트롤이름으로만 조회되는 E_SpdMst 에 있고 SheetMe 는 그 행을 만들지 못한다.
/// 복제본은 이름이 달라 조회 0건 → 레거시가 빈 파일로 FpSpread.Open 을 시도하다 예외 →
/// LoadObject 에 try/catch 가 없어 최상위로 전파되어 그 서식이 통째로 열리지 않는다.
/// 선택 전체를 막지 않고 표만 걸러내는 이유: "라벨 20개 + 표 1개" 복사 같은 정상 작업까지 막히기 때문.
///
public static (List Pastable, int BlockedSpreads) FilterPastable(IEnumerable source)
{
var all = source.ToList();
var pastable = all.Where(element => !ContainsSpread(element)).ToList();
return (pastable, all.Count - pastable.Count);
}
/// 선택 z-order 변경 — 모델 index 0 = 최상위 / VM 마지막 = 최상위
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();
}
///
/// 같은 부모 안에서 임의 위치로 z-order 이동 — 레이어 목록 드래그 재정렬의 모델 연산.
///
/// 은 맨 앞/맨 뒤 두 극단뿐이고 컨테이너 자식은 아예 건너뛴다.
/// 여기서는 최상위든 컨테이너 자식이든 제 부모 컬렉션 안에서 원하는 자리로 옮긴다.
/// 부모가 다른 컨트롤 사이의 이동(재부모화)은 하지 않는다 — 좌표계가 바뀌는 별개의 연산이다.
///
/// 좌표계 주의: VM 컬렉션은 그리기 순서 (마지막이 최상위), 모델은 XML 순서 (index 0 이
/// 최상위)로 서로 역순이다. 두 리스트를 같이 옮기되 index 를 뒤집어 대응시킨다.
///
/// 옮길 컨트롤
/// 목표 위치(부모의 VM 컬렉션 = 그리기 순서 기준 index)
/// 실제로 순서가 바뀌었으면 true
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;
}
///
/// 레이어 목록 드래그 재정렬 — 를 의
/// 위( =true) 또는 아래로 옮긴다.
///
/// 목록은 z-order 최상위가 맨 위인데 VM 컬렉션은 그리기 순서(마지막이 최상위)라 방향이 반대다.
/// 그래서 "목록에서 위" = "VM 컬렉션에서 뒤"로 뒤집어 계산한다.
///
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);
}
/// 컨트롤이 속한 부모의 (VM 컬렉션, 모델 컬렉션) — 최상위면 페이지, 아니면 컨테이너
private (System.Collections.ObjectModel.ObservableCollection? ViewModels, List? 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);
}
/// 방향키 넛지 — 400ms 코얼레스 Undo
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();
}
/// 활성 페이지 전체 선택
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());
}
///
/// 선택 정렬 — 기준은 언제나 '담고 있는 것' 이다.
///
/// 선택이 2개 이상이면 선택 영역이 그 자리를 대신하고, 1개면 그 컨트롤을 담고 있는 것 —
/// 컨테이너 안이면 컨테이너, 최상위면 페이지 — 이 기준이 된다. 피그마와 같은 규칙이다.
///
/// 컨테이너 자식을 페이지 기준으로 맞추지 않는 이유. X/Y 는 부모 상대좌표라
/// "페이지 왼쪽"에 맞추면 음수가 되어 부모 밖으로 나간다. 디자이너 캔버스는 클리핑을 안 해서
/// 멀쩡히 보이지만 레거시 런타임에서는 부모가 잘라먹는다 — 화면과 인쇄가 갈라지고 눈으로 못 잡는다.
///
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();
}
///
/// 정렬 기준 — 무엇에 맞출 것인가.
///
/// 여럿을 골라 놓고도 "서로 맞추기"와 "종이에 맞추기"는 다른 작업이다.
/// 예전에는 전자만 가능했다.
///
public bool AlignToPage
{
get => alignToPage;
set => SetProperty(ref alignToPage, value);
}
/// 정렬 기준 사각형(월드)
private SheetMe.Core.Layout.SnapRect? ReferenceFor(IReadOnlyList 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;
}
/// 부모 체인에 선택된 것이 있는가
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;
}
/// 정렬 기준을 사람 말로 — 인스펙터 툴팁이 이걸 물고 있다
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 : "부모")} 기준"
: "페이지 기준";
}
/// '선택 영역' 쪽 세그먼트에 뭐라고 쓸 것인가 — 하나만 골랐으면 그건 부모(또는 페이지)다
public string AlignSelectionScopeLabel()
{
var items = Selection.Items;
if (items.Count >= 2)
{
return "선택 영역";
}
return items.Count == 1 && items[0].Parent is not null ? "부모" : "페이지";
}
/// 간격 균등이 뜻을 갖는가 — 사이가 있어야 나눌 것이 있다
public bool CanDistribute() => Selection.Items.Count >= 3;
/// 세로 정렬이 가능한가 — 여러 페이지에 걸친 선택에서는 뜻이 없다
public bool CanAlignVertical()
=> Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
///
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
///
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;
}
// 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);
}
}
///
/// 묶을 수 없는 이유 — 없으면 null.
///
/// 그룹이 진짜 Panel 이 되면서 레거시의 두 가지 성질이 따라온다.
/// 여기서 막지 않으면 캔버스에는 아무 변화도 안 보이는 채로 EMR 동작이 달라진다.
///
private string? GroupBlockReason(PageViewModel page, IReadOnlyList targets)
{
// ① 라디오는 부모가 곧 배타 단위다(레거시 RadioButton 에 GroupName 이 없다).
// '택1' 문항에서 일부만 묶으면 그 일부가 독립 집합이 되어 EMR 에서 답이 둘 선택된다.
if (targets.Any(vm => vm.Type == "RadioButton"))
{
return "라디오 버튼은 패널이 곧 선택 묶음이라, 일부만 묶으면 '하나만 고르기'가 갈라집니다.\n"
+ "라디오는 그룹으로 묶지 마세요.";
}
// ② 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다.
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;
}
private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null;
///
/// 선택 그룹 해제 — 선택에 걸린 그룹의 전 멤버 를 푼다(선택되지 않은 멤버 포함).
/// 일부만 풀면 멤버 1개짜리 반쪽 그룹이 남아 다음 선택부터 동작이 이상해진다.
///
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().FirstOrDefault(c => c.Parent is null)
?? Selection.Items.Select(vm => vm.Parent).OfType()
.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();
}
///
/// 패널 풀기 — 자식을 최상위로 되돌리고 패널을 지운다.
///
/// 자식 좌표를 절대값으로 되돌려야 한다. 안 그러면 패널 원점만큼 좌상단으로 튄다.
/// 패널이 있던 z 자리에 자식들을 그대로 끼워 넣어 쌓임 순서를 보존한다.
///
/// 레거시 디자이너에는 이 조작이 없다 — 거기서 패널을 지우면 자식까지 함께 지워진다.
///
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]);
}
}
///
/// 이 컨트롤을 클릭했을 때 실제로 잡히는 것 — 최상위 컨테이너까지 거슬러 올라간다.
/// 그룹이 진짜 패널이 되면서, 자식을 집으면 그 하나만 끌려가던 문제를 여기서 막는다.
///
public ControlViewModel SelectionUnitOf(ControlViewModel viewModel)
{
var unit = viewModel;
while (unit.Parent is { } parent)
{
unit = parent;
}
return unit;
}
/// 그룹 동료 확장 — GroupId 가 같은 페이지 내 컨트롤 전체(클릭 선택이 그룹 단위가 되도록)
public List GroupMatesOf(ControlViewModel viewModel)
{
// 패널 안의 것은 패널이 곧 선택 단위다 — 자식 하나를 집어도 묶음 전체가 함께 움직여야 한다.
// 안으로 들어가려면 더블클릭한다(InteractionController 가 처리).
var unit = SelectionUnitOf(viewModel);
if (!ReferenceEquals(unit, viewModel))
{
return new List { unit };
}
if (viewModel.Model.GroupId is not { } groupId)
{
return new List { viewModel };
}
var page = PageOf(viewModel);
if (page is null)
{
return new List { viewModel };
}
return page.Controls.Where(c => c.Model.GroupId == groupId).ToList();
}
#endregion
#region Methods - 레이어 목록
/// 그룹 표시 이름 — 없으면 기본값("그룹")
public string GroupNameOf(string groupId)
=> document.GroupNames.TryGetValue(groupId, out var name) && name.Trim().Length > 0
? name
: LayerGroupViewModel.FallbackName;
/// 그룹 이름 변경 — 비우면 키를 지워 기본 표시로 되돌린다(그 경로도 되돌릴 수 있어야 한다)
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;
}
/// 그룹 펼침 상태 — 기본은 펼침
public bool IsGroupExpanded(string groupId) => !collapsedGroups.Contains(groupId);
/// 그룹 펼침/접기 — 목록을 다시 만든다
public void SetGroupExpanded(string groupId, bool expanded)
{
if (expanded ? collapsedGroups.Remove(groupId) : collapsedGroups.Add(groupId))
{
RequestLayerRebuild();
}
}
///
/// 목록 재구성 예약 — 펼침 토글은 목록 안의 버튼 이 일으킨다. 즉시 재구성하면 클릭 이벤트가
/// 끝나기도 전에 그 버튼이 목록에서 떨어져 나가므로 한 틱 미뤄 안전하게 갈아 끼운다.
/// (진단 모드처럼 Application 이 없으면 그냥 즉시 재구성한다)
///
public void RequestLayerRebuild()
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null)
{
RebuildLayerRows();
return;
}
dispatcher.BeginInvoke(new Action(RebuildLayerRows));
}
/// 현재 문서에 살아 있는 그룹 식별자
public HashSet ActiveGroupIds()
{
var ids = new HashSet(StringComparer.Ordinal);
foreach (var control in AllControls())
{
if (control.Model.GroupId is { } id)
{
ids.Add(id);
}
}
return ids;
}
///
/// 그룹 자가치유 — 멤버가 1개 이하로 줄어든 그룹을 풀고, 주인 없는 이름을 지운다.
/// 삭제·Undo·페이지 이동 뒤에 홀로 남은 그룹은 클릭 확장이 무의미하고 폴더만 어지럽힌다.
/// 편집 의도가 아니라 정리라서 Undo 스냅샷을 찍지 않는다.
///
public void CleanupGroups()
{
var counts = new Dictionary(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));
}
/// 타입 필터 — 비어 있으면 전체 표시
public IReadOnlyCollection LayerTypeFilter => layerTypeFilter;
/// 타입 필터 교체
public void SetLayerTypeFilter(IEnumerable types)
{
layerTypeFilter.Clear();
foreach (var type in types)
{
layerTypeFilter.Add(type);
}
OnPropertyChanged(nameof(HasLayerTypeFilter));
OnPropertyChanged(nameof(LayerTypeFilterTip));
RebuildLayerRows();
}
/// 문서에 실제로 존재하는 타입 목록(필터 후보) — 개수 많은 순
public List<(string Type, int Count)> LayerTypeCandidates()
{
var counts = new Dictionary(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();
}
///
/// 레이어 목록 재구성 — 문서의 전 페이지 를 한 아웃라인으로 평탄화한다.
/// 페이지 헤더 → (펼쳐져 있으면) 그룹 폴더·컨트롤·컨테이너 자식 → 다음 페이지 → 맨 끝에 '페이지 추가'.
///
/// 순서는 z-order 최상위가 맨 위 다(Figma·[200] 규약). PageViewModel.Controls 는 이미
/// 그리기 순서(마지막이 최상위)라 역순으로 훑는다 — 캔버스에서 위에 겹쳐 보이는 컨트롤을
/// 목록에서도 위에서 찾게 된다.
///
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(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();
}
/// 페이지 직속 컨트롤·그룹 폴더의 들여쓰기 — 페이지 노드 아래라 항상 한 칸 들어간다
private double PageChildIndent => ShowPageNodes ? 12 : 0;
/// 그룹 멤버 들여쓰기 — 페이지 직속보다 한 단계 더
private double GroupMemberIndent => PageChildIndent + 20;
/// 컨테이너(패널/그룹박스) 자식의 추가 들여쓰기
private const double ContainerChildIndent = 16;
///
/// 선택 강조 3상태 + 연속 블록 병합 계산 — 목록을 다시 만들지 않고 색만 갱신한다.
///
/// 선택이 바뀔 때마다 평탄화를 다시 돌리면 1,067행 서식에서 클릭 한 번에 목록 전체가 재생성된다.
/// 그래서 강조는 별도 경로로 분리했다.
///
/// 그룹 집계는 전 페이지 를 대상으로 한다 — 그룹 식별자는 페이지를 넘나들 수 있고,
/// 폴더가 파랑이 되려면 "그 그룹의 모든 멤버"가 선택돼 있어야 한다.
///
public void UpdateLayerHighlight()
{
var total = new Dictionary(StringComparer.Ordinal);
var selected = new Dictionary(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);
}
}
///
/// 목록 재구성 중 — 이때 ListBox 가 쏟아내는 선택 해제는 사용자의 조작이 아니다.
/// (LayerRows.Clear() 가 SelectedItems 를 비우면서 SelectionChanged 를 발화한다)
///
public bool IsRebuildingLayers { get; private set; }
/// 레이어 목록 재구성 완료 — 목록 강조를 되살리라는 신호
public event Action? LayerRowsRebuilt;
/// 컨트롤 행 + (펼쳐져 있으면) 컨테이너 자식 행을 재귀로 추가
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);
}
}
///
/// 검색어·타입 필터 통과 판정 — 자손이 걸리면 조상도 통과시킨다.
/// 그러지 않으면 패널 안의 컨트롤을 검색했을 때 부모가 빠져 자식이 통째로 사라진다.
///
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);
}
}
/// 선택 집합이 바뀌었을 때 — 강조 3상태와 그룹 폴더의 눈·자물쇠 표시를 다시 계산
private void LayerSelectionChanged()
{
UpdateLayerHighlight();
foreach (var group in LayerRows.OfType())
{
group.NotifyFlagsChanged();
}
}
/// 레이어 행 → 선택 대상 컨트롤 목록. 페이지 행·추가 행은 선택 대상이 아니라 빈 목록
public static IEnumerable ControlsOfLayerRow(object? row) => row switch
{
ControlViewModel control => new[] { control },
LayerGroupViewModel group => group.Members,
_ => Enumerable.Empty(),
};
/// 페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 페이지 인스턴스 로 잡는다
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)
public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page);
/// 페이지 펼침/접기
public void SetPageExpanded(PageViewModel page, bool expanded)
{
if (expanded ? collapsedPages.Remove(page) : collapsedPages.Add(page))
{
RequestLayerRebuild();
}
}
///
/// 페이지 헤더 행 클릭 — 활성 페이지로 삼고, 캔버스를 그 페이지로 옮기고, 속성 패널을 페이지로 돌린다.
///
/// 페이지도 레거시에서는 하나의 Object (MDesignerHost)라 자기 속성을 갖는다.
/// 컨트롤을 하나도 고르지 않은 상태에서만 페이지 속성을 보여주므로 선택은 비운다.
///
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);
}
///
/// 속성 패널이 들여다보는 페이지 — 컨트롤 선택이 없을 때만 유효하다.
/// 컨트롤을 고르는 순간 경로에서 비워진다.
///
public PageViewModel? InspectedPage
{
get => inspectedPage;
private set
{
if (!ReferenceEquals(inspectedPage, value))
{
inspectedPage = value;
Inspector.Rebuild();
}
}
}
/// 선택이 생기면 페이지 속성 모드를 벗어난다
private void ClearInspectedPageOnSelection()
{
if (inspectedPage is not null && Selection.Items.Count > 0)
{
inspectedPage = null;
}
}
/// 캔버스를 특정 페이지로 스크롤하라는 요청 — 뷰가 구독한다
public event Action? ScrollToPageRequested;
/// 잘라내기 — 복사 후 삭제
public void CutSelection()
{
if (Selection.Items.Count == 0)
{
return;
}
CopySelection();
DeleteSelection();
}
/// 같은 크기로 — 기준(Primary) 컨트롤의 너비/높이에 맞춤 (레거시 TK_SizeToControl 계열)
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();
}
///
/// 간격 조절 — 가로/세로 균등·넓게·좁게·붙이기 (레거시 TK_Horiz/VertSpace 계열).
/// 축 정렬 순서로 첫 컨트롤 고정, 이후 컨트롤 이동. 넓게/좁게 단위는 그리드 크기.
///
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();
}
/// 페이지 가운데 맞춤 — 선택 묶음을 페이지 가로/세로 중앙으로(상대 배치 유지, 레거시 TK_Center 계열)
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();
}
/// 드래그 종료 후 소속 페이지 재배정 — 컨트롤 중심의 월드 Y 기준
public void ReassignPagesAfterMove(List 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 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
}