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