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)); /// /// 저장하지 않은 변경이 있는가 — 탭에 점으로 보인다. /// /// 이 상태는 가 저장 지점 깊이까지 추적해 정교하게 들고 /// 있었는데(되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다. /// 서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤에는, 어느 것이 안 저장됐는지 /// 확인할 방법이 없다 — 탭 제목이 저장 전후로 글자 하나 다르지 않다. /// 그래서 안전하게 전부 다시 저장하게 되는데, DB 저장은 새 버전을 만든다 — /// 서식마다 쓸데없는 버전이 쌓인다. /// public bool IsDirty => Undo.IsDirty; /// 더티 표시 갱신 통지 — 편집·저장·되돌리기 뒤에 부른다 public void NotifyDirtyChanged() => OnPropertyChanged(nameof(IsDirty)); /// 페이지 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); // 저장 안 됨 표시가 탭에 따라오게 한다 — 값이 바뀔 때만 울린다 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 /// 실행 취소 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 복원 경로. /// /// 선택을 되살린다. 재구성의 첫 줄이 Selection.Clear() 라, 전에는 되돌리기 한 번마다 /// 선택이 통째로 증발했다. 여러 개를 함께 고쳐 보는 작업의 흐름이 /// '여럿 고르기 → 바꾸기 → 눈으로 확인 → Ctrl+Z → 다른 값으로 다시' 인데 /// 첫 되돌리기에서 끊기고, 마퀴로 만든 열두 개 선택을 매번 다시 만들어야 했다. /// /// 기준은 Id 다 — 문서 안에서 유일하고(IdGenerator.CollectUsed) 문서를 갈아끼워도 같다. /// 못 찾은 것은 조용히 뺀다(삭제를 다시 실행한 경우처럼 정말 없어졌을 수 있다). /// 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(keepIds, StringComparer.OrdinalIgnoreCase); var found = new List(); ControlViewModel? primary = null; void Visit(IEnumerable 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]); } } /// 페이지 세로 오프셋 재계산 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); } /// /// 이 좌표에 무엇이든 있는가 — 잠긴 것·숨긴 것까지 포함해서. /// /// 은 잠금·숨김을 후보에서 아예 빼므로 그 위를 "빈 곳"이라고 답한다. /// 선택을 푸는 판단에는 그 답을 쓰면 안 된다 — 잠가 둔 배경 괘선 위에서 우클릭할 때마다 /// 선택이 사라져 "왜 자꾸 풀리지"가 된다. 잠금은 편집 보호지 부재가 아니다. /// 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 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 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개)에서는 목록에서 이름을 찾아도 그게 종이 어디에 /// 있는지 알 수 없었다. 다중 페이지 서식이면 다른 페이지에 있을 수도 있어 더 그렇다. /// 스크롤은 뷰포트 크기를 알아야 하므로 캔버스 뷰에 위임한다. /// /// /// 형제 중 다음(또는 이전) 컨트롤을 고른다 — Tab / Shift+Tab. /// /// 왜 z-order 가 아니라 읽는 순서인가. 계획서는 z-order 순회를 적었지만, /// 이 도구에서 Tab 을 누르는 이유는 "서식을 위에서 아래로 훑으며 문구를 고치는 것"이다. /// 기록지는 위에서 아래·왼쪽에서 오른쪽으로 읽으므로 그 순서가 손에 맞는다. /// z-order 는 겹침을 정하는 축이라 화면상 순서와 무관하게 튄다. /// (EMR 런타임 입력 순서인 와는 다른 개념이다 — 그건 저장되는 값이다.) /// /// 범위는 형제다. 컨테이너 안으로는 Enter, 밖으로는 Esc 로 오간다 — /// 전체를 한 줄로 이으면 패널 안 라디오 15,026개를 지나야 다음 항목에 닿는다. /// 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); } /// Enter — 컨테이너면 안으로 들어가고, 글자를 담는 것이면 편집을 시작한다 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; } /// Esc — 컨테이너 밖으로 한 겹 나간다. 나갈 곳이 없으면 false(호출부가 선택을 푼다) public bool SelectParent() { if (Selection.Primary?.Parent is not { } parent) { return false; } Selection.SetSingle(parent); RevealControl(parent); return true; } /// 이 컨트롤과 같은 부모를 가진 것들 — 부모가 없으면 페이지 최상위 private List 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 - 인라인 텍스트 편집 /// 더블클릭 인라인 편집 가능한 타입인지 — 레지스트리에 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(); } /// /// 선택분을 제자리에 복제하고 복제본을 선택한다 — Ctrl+드래그 복사가 쓴다. /// /// 붙여넣기와 다른 점: 오프셋을 주지 않고, 원래 부모(컨테이너 자식이면 그 컨테이너) /// 안에 넣는다. 그래서 라디오를 Ctrl+드래그로 복사하면 같은 패널 안에 남아 /// 같은 문항의 선택지가 하나 늘어난다 — 패널 밖으로 튀어나가면 배타 묶음이 갈라진다. /// /// 표(Spread)는 복제하지 않는다(격자가 서식코드+이름으로 별도 테이블에서 오므로 /// 복제본에는 격자가 없다 — 와 같은 이유). /// 복제에 실패하면(전부 표였다) false — 호출부가 이동으로 되돌린다. /// 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(StringComparer.Ordinal); var copies = new List(); 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; } /// 복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다 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; /// /// 선택 그룹화 — 선택분(2개 이상, 최상위만)을 감싸는 진짜 Panel 을 만들어 자식으로 옮긴다. /// 그래서 저장까지 살아남는다(레거시 XML 의 children="Controls" 중첩 Object). /// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다). /// /// 라디오에 특히 중요하다: 레거시는 GroupName 이 없어 직계 부모가 곧 배타 단위이고, /// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 자식이다(그룹 5,582개, 평균 2.72개). /// 즉 이 조작이 "문항 만들기" 다. /// 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; } // 라디오는 패널이 곧 문항이다 — 묶는 것이 정상이고 필요한 작업이다 // (운영 실측: 라디오 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); } } /// /// 묶을 수 없는 이유 — 없으면 null. /// /// 그룹이 진짜 Panel 이 되면서 레거시의 두 가지 성질이 따라온다. /// 여기서 막지 않으면 캔버스에는 아무 변화도 안 보이는 채로 EMR 동작이 달라진다. /// private string? GroupBlockReason(PageViewModel page, IReadOnlyList 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; } /// /// 라디오 문항이 갈라지는가 — 갈라지지 않으면 null. /// /// 왜 되묻는가. 레거시 RadioButton 은 WinForms 를 그대로 상속해 /// GroupName 이 없고 직계 부모가 곧 배타 단위다(RadioButton.vb:7-8). /// 그래서 패널로 묶는 것 자체는 정상이고 필요한 작업이지만, /// 같은 자리에 있던 라디오 중 일부만 묶으면 한 문항이 두 묶음으로 갈라져 /// EMR 에서 답이 둘 선택된다 — 캔버스에는 아무 변화도 안 보인 채로. /// internal string? RadioSplitWarning(PageViewModel page, IReadOnlyList 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; /// /// 선택 그룹 해제 — 선택에 걸린 그룹의 전 멤버를 푼다(선택되지 않은 멤버 포함). /// 일부만 풀면 멤버 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 계열). /// 축 정렬 순서로 첫 컨트롤 고정, 이후 컨트롤 이동. 넓게/좁게 단위는 그리드 크기. /// /// /// 선택한 것들 사이 간격을 정확한 픽셀 값으로 놓는다. /// /// 왜 필요한가. 여기까지 간격을 조절하는 길은 4px 씩 넓게/좁게뿐이었다. /// 20px 를 32px 로 바꾸려면 3번, 로고 아래 4단 메뉴로 하면 12왕복이었다. /// 단축키(Alt+[ / Alt+])를 붙여 왕복은 없앴지만 여전히 4px 씩이다. /// 드래그 스냅은 형제 간격을 정확히 계산해 분홍 표식까지 그리는데(SnapSolver), /// 명령 경로에는 그 값을 넣을 칸이 없었다. /// /// 첫 항목은 움직이지 않는다 — 기준이 흔들리면 같은 값을 두 번 넣어도 자리가 계속 밀린다. /// 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(); } /// /// 지금 선택의 간격 — 숫자 칸에 무엇을 보여 줄지. /// 값이 서로 다르면 null(빈 칸) — 하나로 보여 주면 그 값이 이미 적용된 것처럼 읽힌다. /// 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(); 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(); } /// 페이지 가운데 맞춤 — 선택 묶음을 페이지 가로/세로 중앙으로(상대 배치 유지, 레거시 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 }