초기 커밋: SheetMe 서식생성기 (P0~P5 완료 상태)
레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너. 기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49. 이 커밋에 함께 포함된 자격증명 분리: - appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환 - 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외, csproj Debug 조건부 복사라 Release 산출물에 실리지 않음) - ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경, 미치환 플레이스홀더는 '미설정'으로 간주 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// 캔버스 컨트롤 ViewModel 공통 — 모델(ControlElement)의 관찰 가능한 투영.
|
||||
/// 모델이 진실이며 VM 은 버려도 되는 투영(Undo 복원 시 재생성)이다.
|
||||
/// 좌표계: WinForms px = WPF DIP 1:1.
|
||||
/// </summary>
|
||||
public abstract class ControlViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private bool isSelected;
|
||||
private bool isHovered;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>원본 모델(저장 원본 — 참조 보관)</summary>
|
||||
public ControlElement Model { get; }
|
||||
|
||||
/// <summary>부모 컨테이너 VM — 최상위(페이지 직속)면 null. X/Y 는 부모 기준 상대좌표</summary>
|
||||
public ControlViewModel? Parent { get; set; }
|
||||
|
||||
/// <summary>컨트롤 이름(Id)</summary>
|
||||
public string Id => Model.Id;
|
||||
|
||||
/// <summary>중립 타입명</summary>
|
||||
public string Type => Model.Type;
|
||||
|
||||
/// <summary>부모 기준 X(px)</summary>
|
||||
public double X
|
||||
{
|
||||
get => Model.Bounds.X;
|
||||
set { Model.Bounds.X = value; OnPropertyChanged(nameof(X)); }
|
||||
}
|
||||
|
||||
/// <summary>부모 기준 Y(px)</summary>
|
||||
public double Y
|
||||
{
|
||||
get => Model.Bounds.Y;
|
||||
set { Model.Bounds.Y = value; OnPropertyChanged(nameof(Y)); }
|
||||
}
|
||||
|
||||
/// <summary>너비(px)</summary>
|
||||
public double Width
|
||||
{
|
||||
get => Model.Bounds.W;
|
||||
set { Model.Bounds.W = value; OnPropertyChanged(nameof(Width)); }
|
||||
}
|
||||
|
||||
/// <summary>높이(px)</summary>
|
||||
public double Height
|
||||
{
|
||||
get => Model.Bounds.H;
|
||||
set { Model.Bounds.H = value; OnPropertyChanged(nameof(Height)); }
|
||||
}
|
||||
|
||||
/// <summary>선택 상태(뷰 전용 — 저장 안 함)</summary>
|
||||
public bool IsSelected
|
||||
{
|
||||
get => isSelected;
|
||||
set => SetProperty(ref isSelected, value);
|
||||
}
|
||||
|
||||
/// <summary>호버 상태(뷰 전용)</summary>
|
||||
public bool IsHovered
|
||||
{
|
||||
get => isHovered;
|
||||
set => SetProperty(ref isHovered, value);
|
||||
}
|
||||
|
||||
/// <summary>정적 텍스트(Text Property)</summary>
|
||||
public string Text => Model.Props.GetText("Text") ?? string.Empty;
|
||||
|
||||
/// <summary>유효 폰트 — 부모 체인 상속 반영(WinForms Font 상속 규약)</summary>
|
||||
public LegacyFont EffectiveFont { get; private set; } = new();
|
||||
|
||||
/// <summary>WPF FontFamily</summary>
|
||||
public FontFamily FontFamily => new(EffectiveFont.Family);
|
||||
|
||||
/// <summary>WPF FontSize(DIP) — pt × 96/72</summary>
|
||||
public double FontSize => Math.Max(1, EffectiveFont.SizePt * 96.0 / 72.0);
|
||||
|
||||
/// <summary>굵게</summary>
|
||||
public FontWeight FontWeight => EffectiveFont.Bold ? FontWeights.Bold : FontWeights.Normal;
|
||||
|
||||
/// <summary>기울임</summary>
|
||||
public FontStyle FontStyle => EffectiveFont.Italic ? FontStyles.Italic : FontStyles.Normal;
|
||||
|
||||
/// <summary>글자색 — ForeColor 상속 반영</summary>
|
||||
public Brush Foreground { get; private set; } = Brushes.Black;
|
||||
|
||||
/// <summary>배경색 — BackColor(명시 시)</summary>
|
||||
public Brush Background { get; private set; } = Brushes.Transparent;
|
||||
|
||||
/// <summary>수평 텍스트 정렬 — TextAlign 계열 Property 해석</summary>
|
||||
public HorizontalAlignment TextAlignment { get; private set; } = HorizontalAlignment.Left;
|
||||
|
||||
/// <summary>상속 기점 부모 폰트(재해석용 보관)</summary>
|
||||
public LegacyFont ParentFont { get; private set; } = new();
|
||||
|
||||
/// <summary>상속 기점 부모 글자색(재해석용 보관)</summary>
|
||||
public Brush ParentForeground { get; private set; } = Brushes.Black;
|
||||
|
||||
/// <summary>잠금 플래그(레이어 패널 토글)</summary>
|
||||
public bool IsLockedFlag
|
||||
{
|
||||
get => Model.Locked;
|
||||
set
|
||||
{
|
||||
Model.Locked = value;
|
||||
OnPropertyChanged(nameof(IsLockedFlag));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>숨김 플래그(레이어 패널 토글) — 캔버스 반투명 표시</summary>
|
||||
public bool IsHiddenFlag
|
||||
{
|
||||
get => Model.Hidden;
|
||||
set
|
||||
{
|
||||
Model.Hidden = value;
|
||||
OnPropertyChanged(nameof(IsHiddenFlag));
|
||||
OnPropertyChanged(nameof(DesignOpacity));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>디자인 표시 불투명도 — 숨김이면 0.25</summary>
|
||||
public double DesignOpacity => Model.Hidden ? 0.25 : 1.0;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
protected ControlViewModel(ControlElement model)
|
||||
{
|
||||
Model = model;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 상속 컨텍스트(부모 폰트/글자색)를 적용하고 표시 속성을 재해석한다 — DocumentMapper 가 매핑 시 호출.
|
||||
/// </summary>
|
||||
public virtual void ResolveVisualContext(LegacyFont parentFont, Brush parentForeground)
|
||||
{
|
||||
ParentFont = parentFont;
|
||||
ParentForeground = parentForeground;
|
||||
|
||||
var fontText = Model.Props.GetText("Font");
|
||||
EffectiveFont = fontText is not null ? LegacyFormat.ParseFont(fontText) : parentFont;
|
||||
|
||||
var foreText = Model.Props.GetText("ForeColor");
|
||||
Foreground = foreText is not null ? BrushFromLegacy(foreText) : parentForeground;
|
||||
|
||||
var backText = Model.Props.GetText("BackColor");
|
||||
if (backText is not null)
|
||||
{
|
||||
Background = BrushFromLegacy(backText);
|
||||
}
|
||||
|
||||
var align = Model.Props.GetText("TextAlign") ?? string.Empty;
|
||||
TextAlignment = align.Contains("Center") ? HorizontalAlignment.Center
|
||||
: align.Contains("Right") ? HorizontalAlignment.Right
|
||||
: HorizontalAlignment.Left;
|
||||
|
||||
OnPropertyChanged(string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>레거시 색 문자열 → WPF Brush</summary>
|
||||
protected static Brush BrushFromLegacy(string colorText)
|
||||
{
|
||||
var (a, r, g, b) = LegacyFormat.ParseColor(colorText);
|
||||
var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
|
||||
/// <summary>속성 변경 후 시각 재해석 — 보관된 부모 컨텍스트로 다시 해석</summary>
|
||||
public void RefreshVisual() => ResolveVisualContext(ParentFont, ParentForeground);
|
||||
|
||||
/// <summary>전체 속성 변경 통지(개명 등 계산 속성 갱신)</summary>
|
||||
public void NotifyAllChanged() => OnPropertyChanged(string.Empty);
|
||||
|
||||
/// <summary>텍스트 Property 조회 편의</summary>
|
||||
protected string? Prop(string name) => Model.Props.GetText(name);
|
||||
|
||||
/// <summary>bool Property 조회 편의 — "True"/"False" 문자열</summary>
|
||||
protected bool PropBool(string name, bool defaultValue = false)
|
||||
=> bool.TryParse(Prop(name), out var value) ? value : defaultValue;
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
// 타입별 컨트롤 ViewModel — DataTemplate 자동 선택(캔버스 렌더)의 키.
|
||||
// 각 클래스는 얇은 표시 투영이므로 한 파일에 모아 관리한다.
|
||||
|
||||
/// <summary>라벨 (레거시 Label/MFormatLabel/MSequence)</summary>
|
||||
public sealed class LabelViewModel : ControlViewModel
|
||||
{
|
||||
public LabelViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>텍스트박스 (레거시 TextBox)</summary>
|
||||
public sealed class TextBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>여러 줄 여부</summary>
|
||||
public bool Multiline => PropBool("Multiline");
|
||||
|
||||
/// <summary>테두리 표시 여부 — BorderStyle=None 이면 숨김</summary>
|
||||
public bool ShowBorder => Prop("BorderStyle") != "None";
|
||||
|
||||
public TextBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>마스크 입력 (레거시 MaskedTextBox)</summary>
|
||||
public sealed class MaskedTextBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>입력 마스크</summary>
|
||||
public string Mask => Prop("Mask") ?? string.Empty;
|
||||
|
||||
public MaskedTextBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>체크박스 (레거시 CheckBox/CheckLabel)</summary>
|
||||
public sealed class CheckBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>디자인 표시용 체크 상태</summary>
|
||||
public bool IsChecked => Prop("Checked") == "True";
|
||||
|
||||
public CheckBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>라디오버튼 (레거시 RadioButton)</summary>
|
||||
public sealed class RadioButtonViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>디자인 표시용 선택 상태</summary>
|
||||
public bool IsChecked => Prop("Checked") == "True";
|
||||
|
||||
public RadioButtonViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>콤보박스 (레거시 ComboBox)</summary>
|
||||
public sealed class ComboBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>선택지 목록 — Items Property(ItemsValue) 해석</summary>
|
||||
public IReadOnlyList<string> Items => ItemsOf(Model, "Items");
|
||||
|
||||
/// <summary>디자인 표시 텍스트 — Text 또는 첫 항목</summary>
|
||||
public string DisplayText => Text.Length > 0 ? Text : (Items.Count > 0 ? Items[0] : string.Empty);
|
||||
|
||||
public ComboBoxViewModel(ControlElement model) : base(model) { }
|
||||
|
||||
/// <summary>ItemsValue Property 를 문자열 목록으로</summary>
|
||||
internal static IReadOnlyList<string> ItemsOf(ControlElement model, string propName)
|
||||
{
|
||||
if (model.Props.Get(propName) is LegacyPropValue.ItemsValue items)
|
||||
{
|
||||
return items.Items
|
||||
.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty)
|
||||
.ToList();
|
||||
}
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>리스트박스 (레거시 ListBox)</summary>
|
||||
public sealed class ListBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>항목 목록</summary>
|
||||
public IReadOnlyList<string> Items => ComboBoxViewModel.ItemsOf(Model, "Items");
|
||||
|
||||
public ListBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>체크리스트 (레거시 MCheckedListBox)</summary>
|
||||
public sealed class CheckListViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>항목 목록</summary>
|
||||
public IReadOnlyList<string> Items => ComboBoxViewModel.ItemsOf(Model, "Items");
|
||||
|
||||
public CheckListViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>날짜선택 (레거시 DateTimePicker)</summary>
|
||||
public sealed class DateTimePickerViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>디자인 표시 텍스트 — 포맷 힌트</summary>
|
||||
public string DisplayText
|
||||
{
|
||||
get
|
||||
{
|
||||
var custom = Prop("CustomFormat");
|
||||
if (!string.IsNullOrEmpty(custom))
|
||||
{
|
||||
return custom;
|
||||
}
|
||||
return Prop("Format") switch
|
||||
{
|
||||
"Time" => "오후 12:00:00",
|
||||
"Short" => "2026-01-01",
|
||||
_ => "2026년 1월 1일",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimePickerViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>컨테이너 공통 — 자식 컬렉션 보유(Panel/GroupBox)</summary>
|
||||
public abstract class ContainerViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>자식 컨트롤(그리기 순서 — 마지막이 최상위)</summary>
|
||||
public ObservableCollection<ControlViewModel> Children { get; } = new();
|
||||
|
||||
protected ContainerViewModel(ControlElement model) : base(model) { }
|
||||
|
||||
/// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary>
|
||||
public override void ResolveVisualContext(SheetMe.Core.Serialization.LegacyFont parentFont, System.Windows.Media.Brush parentForeground)
|
||||
{
|
||||
base.ResolveVisualContext(parentFont, parentForeground);
|
||||
foreach (var child in Children)
|
||||
{
|
||||
child.ResolveVisualContext(EffectiveFont, Foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>패널 (레거시 Panel/Panel2/MLayerPanel/MExpandablePanel)</summary>
|
||||
public sealed class PanelViewModel : ContainerViewModel
|
||||
{
|
||||
/// <summary>테두리 표시 여부 — BorderStyle 존재 시(None 제외)</summary>
|
||||
public bool ShowBorder => Prop("BorderStyle") is not (null or "None");
|
||||
|
||||
public PanelViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>그룹박스 (레거시 GroupBox)</summary>
|
||||
public sealed class GroupBoxViewModel : ContainerViewModel
|
||||
{
|
||||
public GroupBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>선 (레거시 MLine)</summary>
|
||||
public sealed class LineViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>선 색 — LineColor Property</summary>
|
||||
public System.Windows.Media.Brush LineBrush
|
||||
=> Prop("LineColor") is { } color ? BrushFromLegacy(color) : System.Windows.Media.Brushes.Black;
|
||||
|
||||
public LineViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>이미지 (레거시 MPictureBox)</summary>
|
||||
public sealed class PictureBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트</summary>
|
||||
public string Hint => Prop("DataInterfaceTag") ?? "이미지";
|
||||
|
||||
public PictureBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>계산박스 (레거시 MCalcBox)</summary>
|
||||
public sealed class CalcBoxViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>수식 원문 — Formula Property</summary>
|
||||
public string Formula => Prop("Formula") ?? string.Empty;
|
||||
|
||||
public CalcBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>버튼 (레거시 MButton : WinForms Button — 클릭 시 DataActionTag 검색폼 호출)</summary>
|
||||
public sealed class ButtonViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>액션 태그 표시(None 이면 빈 값)</summary>
|
||||
public string ActionHint
|
||||
{
|
||||
get
|
||||
{
|
||||
var tag = Prop("DataActionTag");
|
||||
return tag is null or "None" ? string.Empty : tag;
|
||||
}
|
||||
}
|
||||
|
||||
public ButtonViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스 (레거시 MDataTable — 34×34 DB 아이콘, 런타임 비가시 쿼리 소스).
|
||||
/// 디자인 화면에서만 배지로 보이고 인쇄/런타임에는 그려지지 않는다.
|
||||
/// </summary>
|
||||
public sealed class DataTableViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>쿼리 요약(툴팁/배지) — 첫 60자</summary>
|
||||
public string QuerySummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var query = (Prop("Query") ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
return query.Length == 0
|
||||
? "쿼리 미설정"
|
||||
: query.Length > 60 ? query[..60] + "…" : query;
|
||||
}
|
||||
}
|
||||
|
||||
public DataTableViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
/// <summary>Spread 격자선 1개(로컬 좌표)</summary>
|
||||
public sealed class SpreadLine
|
||||
{
|
||||
public double X1 { get; init; }
|
||||
public double Y1 { get; init; }
|
||||
public double X2 { get; init; }
|
||||
public double Y2 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Spread 셀 표시 1개(로컬 좌표)</summary>
|
||||
public sealed class SpreadCellView
|
||||
{
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
public double W { get; init; }
|
||||
public double H { get; init; }
|
||||
public string Text { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 표 (레거시 Spread — FarPoint). 디자인은 E_SpdMst 별도 저장 —
|
||||
/// DB 서식 열기 시 파싱된 격자(GridInfo)를 주입받아 렌더한다(읽기 전용, 편집은 레거시 디자이너 사용).
|
||||
/// </summary>
|
||||
public sealed class SpreadViewModel : ControlViewModel
|
||||
{
|
||||
private SheetMe.Core.Serialization.SpreadGridInfo? gridInfo;
|
||||
|
||||
/// <summary>파싱된 격자 정보 — 주입 시 지오메트리 재계산</summary>
|
||||
public SheetMe.Core.Serialization.SpreadGridInfo? GridInfo
|
||||
{
|
||||
get => gridInfo;
|
||||
set
|
||||
{
|
||||
gridInfo = value;
|
||||
RecalcGeometry();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>격자 정보 보유 여부(없으면 자리표시 렌더)</summary>
|
||||
public bool HasGrid => gridInfo is not null;
|
||||
|
||||
/// <summary>내부 격자선(로컬 좌표, 외곽 제외)</summary>
|
||||
public System.Collections.ObjectModel.ObservableCollection<SpreadLine> GridLines { get; } = new();
|
||||
|
||||
/// <summary>셀 텍스트(로컬 좌표, 스팬 반영)</summary>
|
||||
public System.Collections.ObjectModel.ObservableCollection<SpreadCellView> CellViews { get; } = new();
|
||||
|
||||
public SpreadViewModel(ControlElement model) : base(model)
|
||||
{
|
||||
PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is nameof(Width) or nameof(Height))
|
||||
{
|
||||
RecalcGeometry();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>격자/셀 지오메트리 재계산 — 컨트롤 크기 내 클리핑(500×500 전체가 아닌 보이는 만큼만)</summary>
|
||||
private void RecalcGeometry()
|
||||
{
|
||||
GridLines.Clear();
|
||||
CellViews.Clear();
|
||||
OnPropertyChanged(nameof(HasGrid));
|
||||
if (gridInfo is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var width = Math.Max(1, Width);
|
||||
var height = Math.Max(1, Height);
|
||||
|
||||
// 세로선(열 경계) — 누적 너비가 컨트롤 폭 안인 것만
|
||||
var x = 0.0;
|
||||
var colEdges = new List<double> { 0 };
|
||||
for (var c = 0; x < width && c < 512; c++)
|
||||
{
|
||||
x += gridInfo.ColWidthOf(c);
|
||||
if (x >= width)
|
||||
{
|
||||
break;
|
||||
}
|
||||
colEdges.Add(x);
|
||||
GridLines.Add(new SpreadLine { X1 = x, Y1 = 0, X2 = x, Y2 = height });
|
||||
}
|
||||
|
||||
// 가로선(행 경계)
|
||||
var y = 0.0;
|
||||
var rowEdges = new List<double> { 0 };
|
||||
for (var r = 0; y < height && r < 512; r++)
|
||||
{
|
||||
y += gridInfo.RowHeightOf(r);
|
||||
if (y >= height)
|
||||
{
|
||||
break;
|
||||
}
|
||||
rowEdges.Add(y);
|
||||
GridLines.Add(new SpreadLine { X1 = 0, Y1 = y, X2 = width, Y2 = y });
|
||||
}
|
||||
|
||||
// 셀 텍스트 — 스팬 반영, 보이는 영역만
|
||||
foreach (var cell in gridInfo.Cells)
|
||||
{
|
||||
var span = gridInfo.Spans.FirstOrDefault(s => s.Row == cell.Row && s.Col == cell.Col);
|
||||
var cellX = SumCols(0, cell.Col);
|
||||
var cellY = SumRows(0, cell.Row);
|
||||
if (cellX >= width || cellY >= height)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var cellW = SumCols(cell.Col, cell.Col + (span?.ColSpan ?? 1));
|
||||
var cellH = SumRows(cell.Row, cell.Row + (span?.RowSpan ?? 1));
|
||||
CellViews.Add(new SpreadCellView
|
||||
{
|
||||
X = cellX,
|
||||
Y = cellY,
|
||||
W = Math.Min(cellW, width - cellX),
|
||||
H = Math.Min(cellH, height - cellY),
|
||||
Text = cell.Text,
|
||||
});
|
||||
}
|
||||
|
||||
double SumCols(int from, int to)
|
||||
{
|
||||
var sum = 0.0;
|
||||
for (var c = from; c < to; c++)
|
||||
{
|
||||
sum += gridInfo!.ColWidthOf(c);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
double SumRows(int from, int to)
|
||||
{
|
||||
var sum = 0.0;
|
||||
for (var r = from; r < to; r++)
|
||||
{
|
||||
sum += gridInfo!.RowHeightOf(r);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>미지원 컨트롤 자리표시 — 원본 타입명 표시, 이동/리사이즈만 허용</summary>
|
||||
public sealed class PlaceholderViewModel : ControlViewModel
|
||||
{
|
||||
/// <summary>원본 레거시 클래스명</summary>
|
||||
public string LegacyClassName
|
||||
=> Model.LegacyAqn is { } aqn ? LegacyTypeCatalog.ShortClassName(aqn) : "Unknown";
|
||||
|
||||
public PlaceholderViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels.Inspector;
|
||||
|
||||
/// <summary>
|
||||
/// 속성 인스펙터 — ControlRegistry 스키마 기반 행 구성.
|
||||
/// 선택 '집합' 변경 시에만 재구성(드래그 프레임 제외), 다중선택은 값 동일성 병합("여러 값").
|
||||
/// 커밋 규약: 값 변경 시 Undo 스냅샷 1회 → 전체 선택 대상 적용 → 시각 재해석.
|
||||
/// </summary>
|
||||
public sealed class InspectorViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private const string StringItemAqn =
|
||||
"System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
|
||||
private readonly DesignerViewModel designer;
|
||||
private readonly List<(PropertyRowViewModel Row, RowBinding Binding)> boundsRows = new();
|
||||
private bool showAdvanced;
|
||||
|
||||
/// <summary>공통/글꼴 섹션이 이미 다루는 키 — 고급 목록에서 제외</summary>
|
||||
private static readonly HashSet<string> HandledKeys = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Location", "Size", "LocationOnBase", "Name", "Font",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>행 목록(섹션 헤더 포함)</summary>
|
||||
public ObservableCollection<PropertyRowViewModel> Rows { get; } = new();
|
||||
|
||||
/// <summary>선택 요약 텍스트</summary>
|
||||
public string Summary
|
||||
=> designer.Selection.Items.Count switch
|
||||
{
|
||||
0 => "선택 없음",
|
||||
1 => $"{designer.Selection.Primary!.Type} — {designer.Selection.Primary!.Id}",
|
||||
var n => $"{n}개 선택",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public InspectorViewModel(DesignerViewModel designer)
|
||||
{
|
||||
this.designer = designer;
|
||||
designer.Selection.SetChanged += Rebuild;
|
||||
designer.Selection.Changed += RefreshBoundsRows; // 드래그/리사이즈 중 X/Y/W/H 실시간 갱신
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>경계(X/Y/W/H) 행 값만 갱신 — 이동/리사이즈 프레임(재구성 없이 가볍게)</summary>
|
||||
private void RefreshBoundsRows()
|
||||
{
|
||||
var items = designer.Selection.Items;
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (var (row, binding) in boundsRows)
|
||||
{
|
||||
var values = items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
|
||||
row.Initialize(values.Count == 1 ? values[0] : null, isMixed: values.Count > 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>행 재구성 — 선택 집합 변경 시</summary>
|
||||
public void Rebuild()
|
||||
{
|
||||
Rows.Clear();
|
||||
boundsRows.Clear();
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
|
||||
var items = designer.Selection.Items;
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 공통: 이름/위치/크기
|
||||
Rows.Add(new SectionRowViewModel("공통"));
|
||||
if (items.Count == 1)
|
||||
{
|
||||
AddRow(new TextRowViewModel("이름"), new RowBinding
|
||||
{
|
||||
Get = vm => vm.Id,
|
||||
Set = (vm, value) => RenameControl(vm, value),
|
||||
AffectsVisual = false,
|
||||
});
|
||||
}
|
||||
AddBoundsRow("X", vm => vm.X, (vm, v) => vm.X = v);
|
||||
AddBoundsRow("Y", vm => vm.Y, (vm, v) => vm.Y = v);
|
||||
AddBoundsRow("너비", vm => vm.Width, (vm, v) => vm.Width = Math.Max(1, v));
|
||||
AddBoundsRow("높이", vm => vm.Height, (vm, v) => vm.Height = Math.Max(1, v));
|
||||
|
||||
// 폰트(공통) — Font Property(상속 시 빈 값)
|
||||
Rows.Add(new SectionRowViewModel("글꼴"));
|
||||
AddFontRow("글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v);
|
||||
AddFontRow("크기(pt)", f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture),
|
||||
(f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0 ? size : f.SizePt);
|
||||
AddFontToggleRow("굵게", f => f.Bold, (f, v) => f.Bold = v);
|
||||
AddFontToggleRow("밑줄", f => f.Underline, (f, v) => f.Underline = v);
|
||||
|
||||
// 타입 전용 — 전체 선택이 같은 타입일 때만
|
||||
var type = items[0].Type;
|
||||
var curatedKeys = new HashSet<string>(HandledKeys, StringComparer.Ordinal);
|
||||
if (items.All(i => i.Type == type) && ControlRegistry.Find(type) is { } descriptor && descriptor.Properties.Count > 0)
|
||||
{
|
||||
Rows.Add(new SectionRowViewModel(descriptor.DisplayName));
|
||||
foreach (var def in descriptor.Properties)
|
||||
{
|
||||
AddDefRow(def);
|
||||
curatedKeys.Add(def.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가)
|
||||
if (items.Count == 1)
|
||||
{
|
||||
BuildAdvancedRows(items[0], curatedKeys);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary>
|
||||
private void BuildAdvancedRows(ControlViewModel target, HashSet<string> curatedKeys)
|
||||
{
|
||||
var advancedKeys = target.Model.Props.Keys
|
||||
.Where(k => !curatedKeys.Contains(k))
|
||||
.ToList();
|
||||
|
||||
if (!showAdvanced)
|
||||
{
|
||||
Rows.Add(new ToggleAdvancedRowViewModel($"전체 속성 표시 ▾ ({advancedKeys.Count}개)", () =>
|
||||
{
|
||||
showAdvanced = true;
|
||||
Rebuild();
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
Rows.Add(new SectionRowViewModel("전체 속성 (고급 — 레거시 원문)"));
|
||||
Rows.Add(new ToggleAdvancedRowViewModel("전체 속성 숨기기 ▴", () =>
|
||||
{
|
||||
showAdvanced = false;
|
||||
Rebuild();
|
||||
}));
|
||||
|
||||
foreach (var key in advancedKeys)
|
||||
{
|
||||
var value = target.Model.Props.Get(key);
|
||||
switch (value)
|
||||
{
|
||||
case LegacyPropValue.TextValue or LegacyPropValue.NullValue or null:
|
||||
AddRow(new TextRowViewModel(key), new RowBinding
|
||||
{
|
||||
Get = vm => vm.Model.Props.GetText(key) ?? string.Empty,
|
||||
Set = (vm, newValue) => vm.Model.Props.SetText(key, newValue),
|
||||
});
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ItemsValue items:
|
||||
AddRow(new MultilineTextRowViewModel(key + " (목록)"), new RowBinding
|
||||
{
|
||||
Get = vm => vm.Model.Props.Get(key) is LegacyPropValue.ItemsValue iv
|
||||
? string.Join("\n", iv.Items.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty))
|
||||
: string.Empty,
|
||||
Set = (vm, newValue) =>
|
||||
{
|
||||
var itemsValue = new LegacyPropValue.ItemsValue();
|
||||
var aqn = items.Items.FirstOrDefault()?.Aqn ?? StringItemAqn;
|
||||
foreach (var line in newValue.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
itemsValue.Items.Add(new LegacyItem { Aqn = aqn, Value = new LegacyPropValue.TextValue(line) });
|
||||
}
|
||||
vm.Model.Props.Set(key, itemsValue);
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case LegacyPropValue.NestedValue nested:
|
||||
Rows.Add(new ReadOnlyRowViewModel(key, $"(중첩 속성 {nested.Children.Count}개 — 보존됨)"));
|
||||
break;
|
||||
|
||||
case LegacyPropValue.BinaryValue:
|
||||
Rows.Add(new ReadOnlyRowViewModel(key, "(바이너리 — 원문 보존됨)"));
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ReferenceValue reference:
|
||||
Rows.Add(new ReadOnlyRowViewModel(key, $"(참조: {reference.Name})"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 속성 추가 — 레거시 컨트롤의 임의 속성 지정(오타 주의: 로더가 모르는 키는 경고 처리)
|
||||
Rows.Add(new AddPropertyRowViewModel(key =>
|
||||
{
|
||||
if (target.Model.Props.Contains(key))
|
||||
{
|
||||
System.Windows.MessageBox.Show($"이미 존재하는 속성입니다: {key}", "속성 추가");
|
||||
return;
|
||||
}
|
||||
designer.Undo.Snapshot();
|
||||
target.Model.Props.SetText(key, string.Empty);
|
||||
Rebuild();
|
||||
}));
|
||||
}
|
||||
|
||||
private void AddDefRow(PropertyDef def)
|
||||
{
|
||||
var binding = def.Editor == PropEditorKind.StringList
|
||||
? new RowBinding
|
||||
{
|
||||
Get = vm => vm.Model.Props.Get(def.Key) is LegacyPropValue.ItemsValue items
|
||||
? string.Join("\n", items.Items.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty))
|
||||
: string.Empty,
|
||||
Set = (vm, value) =>
|
||||
{
|
||||
var itemsValue = new LegacyPropValue.ItemsValue();
|
||||
foreach (var line in value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
itemsValue.Items.Add(new LegacyItem { Aqn = StringItemAqn, Value = new LegacyPropValue.TextValue(line) });
|
||||
}
|
||||
vm.Model.Props.Set(def.Key, itemsValue);
|
||||
},
|
||||
}
|
||||
: new RowBinding
|
||||
{
|
||||
Get = vm => vm.Model.Props.GetText(def.Key),
|
||||
Set = (vm, value) =>
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
vm.Model.Props.Remove(def.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
vm.Model.Props.SetText(def.Key, value);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
PropertyRowViewModel row = def.Editor switch
|
||||
{
|
||||
PropEditorKind.MultilineText or PropEditorKind.StringList => new MultilineTextRowViewModel(def.Label),
|
||||
PropEditorKind.Number => new NumberRowViewModel(def.Label),
|
||||
PropEditorKind.Toggle => new ToggleRowViewModel(def.Label),
|
||||
PropEditorKind.Choice => new ChoiceRowViewModel(def.Label, def.Choices ?? Array.Empty<string>()),
|
||||
PropEditorKind.Color => new ColorRowViewModel(def.Label),
|
||||
PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label,
|
||||
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags),
|
||||
PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label,
|
||||
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags),
|
||||
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label),
|
||||
_ => new TextRowViewModel(def.Label),
|
||||
};
|
||||
AddRow(row, binding);
|
||||
}
|
||||
|
||||
private void AddBoundsRow(string label, Func<ControlViewModel, double> get, Action<ControlViewModel, double> set)
|
||||
{
|
||||
var row = new NumberRowViewModel(label);
|
||||
var binding = new RowBinding
|
||||
{
|
||||
Get = vm => Math.Round(get(vm)).ToString(CultureInfo.InvariantCulture),
|
||||
Set = (vm, value) =>
|
||||
{
|
||||
if (double.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var number))
|
||||
{
|
||||
set(vm, Math.Round(number));
|
||||
}
|
||||
},
|
||||
AffectsVisual = false,
|
||||
};
|
||||
AddRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged());
|
||||
boundsRows.Add((row, binding));
|
||||
}
|
||||
|
||||
private void AddFontRow(string label, Func<LegacyFont, string> get, Action<LegacyFont, string> set)
|
||||
{
|
||||
AddRow(new TextRowViewModel(label), new RowBinding
|
||||
{
|
||||
Get = vm => get(vm.EffectiveFont),
|
||||
Set = (vm, value) =>
|
||||
{
|
||||
var font = CurrentFontOf(vm);
|
||||
set(font, value);
|
||||
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private void AddFontToggleRow(string label, Func<LegacyFont, bool> get, Action<LegacyFont, bool> set)
|
||||
{
|
||||
AddRow(new ToggleRowViewModel(label), new RowBinding
|
||||
{
|
||||
Get = vm => get(vm.EffectiveFont) ? "True" : "False",
|
||||
Set = (vm, value) =>
|
||||
{
|
||||
var font = CurrentFontOf(vm);
|
||||
set(font, value == "True");
|
||||
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화</summary>
|
||||
private static LegacyFont CurrentFontOf(ControlViewModel vm)
|
||||
{
|
||||
var explicitFont = vm.Model.Props.GetText("Font");
|
||||
if (explicitFont is not null)
|
||||
{
|
||||
return LegacyFormat.ParseFont(explicitFont);
|
||||
}
|
||||
var inherited = vm.EffectiveFont;
|
||||
return new LegacyFont
|
||||
{
|
||||
Family = inherited.Family,
|
||||
SizePt = inherited.SizePt,
|
||||
Bold = inherited.Bold,
|
||||
Italic = inherited.Italic,
|
||||
Underline = inherited.Underline,
|
||||
Strikeout = inherited.Strikeout,
|
||||
};
|
||||
}
|
||||
|
||||
private void AddRow(PropertyRowViewModel row, RowBinding binding, Action? afterCommit = null)
|
||||
{
|
||||
var items = designer.Selection.Items;
|
||||
var values = items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
|
||||
row.Initialize(values.Count == 1 ? values[0] : null, isMixed: values.Count > 1);
|
||||
|
||||
row.Commit = value =>
|
||||
{
|
||||
var targets = designer.Selection.Items.ToList();
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
designer.Undo.Snapshot();
|
||||
foreach (var target in targets)
|
||||
{
|
||||
binding.Set(target, value);
|
||||
if (binding.AffectsVisual)
|
||||
{
|
||||
target.RefreshVisual();
|
||||
}
|
||||
target.NotifyAllChanged();
|
||||
}
|
||||
afterCommit?.Invoke();
|
||||
};
|
||||
Rows.Add(row);
|
||||
}
|
||||
|
||||
private void RenameControl(ControlViewModel vm, string newId)
|
||||
{
|
||||
var trimmed = newId.Trim();
|
||||
if (trimmed.Length == 0 || trimmed == vm.Id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var used = IdGenerator.CollectUsed(designer.Document);
|
||||
used.Remove(vm.Id);
|
||||
if (used.Contains(trimmed))
|
||||
{
|
||||
System.Windows.MessageBox.Show($"이미 사용 중인 이름입니다: {trimmed}", "이름 변경");
|
||||
return;
|
||||
}
|
||||
vm.Model.Id = trimmed;
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels.Inspector;
|
||||
|
||||
/// <summary>
|
||||
/// 인스펙터 행 ViewModel — 라벨 + 문자열 정규화 값. 커밋 시(값 변경 시에만) 소유자 콜백으로
|
||||
/// Undo 스냅샷 → 전체 선택 대상 적용 → 시각 재해석이 수행된다.
|
||||
/// </summary>
|
||||
public abstract class PropertyRowViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private string valueText = string.Empty;
|
||||
private bool building;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>행 라벨(한글)</summary>
|
||||
public string Label { get; }
|
||||
|
||||
/// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary>
|
||||
public bool IsMixed { get; private set; }
|
||||
|
||||
/// <summary>정규화 문자열 값 — 파생 편집기가 형 변환</summary>
|
||||
public string ValueText
|
||||
{
|
||||
get => valueText;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref valueText, value) || building)
|
||||
{
|
||||
return;
|
||||
}
|
||||
IsMixed = false;
|
||||
OnPropertyChanged(nameof(IsMixed));
|
||||
Commit?.Invoke(value);
|
||||
OnValueApplied();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>커밋 콜백 — InspectorViewModel 이 배선(스냅샷+적용)</summary>
|
||||
public Action<string>? Commit { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
protected PropertyRowViewModel(string label)
|
||||
{
|
||||
Label = label;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>초기값 세팅(커밋 미발생)</summary>
|
||||
public void Initialize(string? value, bool isMixed = false)
|
||||
{
|
||||
building = true;
|
||||
valueText = value ?? string.Empty;
|
||||
IsMixed = isMixed;
|
||||
OnPropertyChanged(nameof(ValueText));
|
||||
OnPropertyChanged(nameof(IsMixed));
|
||||
building = false;
|
||||
}
|
||||
|
||||
/// <summary>값 적용 후 파생 갱신 지점</summary>
|
||||
protected virtual void OnValueApplied() { }
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>구분 헤더 행</summary>
|
||||
public sealed class SectionRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
public SectionRowViewModel(string label) : base(label) { }
|
||||
}
|
||||
|
||||
/// <summary>한 줄 문자열 행</summary>
|
||||
public sealed class TextRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
public TextRowViewModel(string label) : base(label) { }
|
||||
}
|
||||
|
||||
/// <summary>여러 줄 문자열 행(Text/수식/항목 목록)</summary>
|
||||
public sealed class MultilineTextRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
public MultilineTextRowViewModel(string label) : base(label) { }
|
||||
}
|
||||
|
||||
/// <summary>숫자 행 — 문자열 바인딩, 커밋 시 숫자 검증은 소유자에서</summary>
|
||||
public sealed class NumberRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
public NumberRowViewModel(string label) : base(label) { }
|
||||
}
|
||||
|
||||
/// <summary>토글(참/거짓) 행</summary>
|
||||
public sealed class ToggleRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>체크 상태 — "True"/"False" 문자열과 동기</summary>
|
||||
public bool IsOn
|
||||
{
|
||||
get => ValueText == "True";
|
||||
set => ValueText = value ? "True" : "False";
|
||||
}
|
||||
|
||||
public ToggleRowViewModel(string label) : base(label) { }
|
||||
|
||||
protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn));
|
||||
}
|
||||
|
||||
/// <summary>선택지 행</summary>
|
||||
public sealed class ChoiceRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>선택지 목록</summary>
|
||||
public string[] Choices { get; }
|
||||
|
||||
public ChoiceRowViewModel(string label, string[] choices) : base(label)
|
||||
{
|
||||
Choices = choices;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 태그 피커 행 — 값 표시 + 찾아보기 버튼(검색 대화상자).
|
||||
/// 목록에 없는 사이트 커스텀 태그는 텍스트 직접 입력도 허용.
|
||||
/// </summary>
|
||||
public sealed class TagPickerRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>피커 선택지(레거시 카탈로그)</summary>
|
||||
public IReadOnlyList<string> Choices { get; }
|
||||
|
||||
/// <summary>피커 대화상자 제목</summary>
|
||||
public string PickerTitle { get; }
|
||||
|
||||
/// <summary>찾아보기 — 검색 대화상자 열기</summary>
|
||||
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
|
||||
|
||||
public TagPickerRowViewModel(string label, string pickerTitle, IReadOnlyList<string> choices) : base(label)
|
||||
{
|
||||
PickerTitle = pickerTitle;
|
||||
Choices = choices;
|
||||
BrowseCommand = new M.Framework.WPF.Command((sender, e) => OnBrowse());
|
||||
}
|
||||
|
||||
private void OnBrowse()
|
||||
{
|
||||
var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText)
|
||||
{
|
||||
Owner = System.Windows.Application.Current.MainWindow,
|
||||
};
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
ValueText = dialog.SelectedTag ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>SQL 쿼리 행 — 요약 표시 + 전용 편집기 창(치환 변수 삽입)</summary>
|
||||
public sealed class QueryRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>요약 텍스트(한 줄)</summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>전용 편집기 열기</summary>
|
||||
public M.Framework.WPF.ICustomCommand? EditCommand { get; set; }
|
||||
|
||||
public QueryRowViewModel(string label) : base(label)
|
||||
{
|
||||
EditCommand = new M.Framework.WPF.Command((sender, e) => OnEdit());
|
||||
}
|
||||
|
||||
protected override void OnValueApplied() => OnPropertyChanged(nameof(Summary));
|
||||
|
||||
private void OnEdit()
|
||||
{
|
||||
var dialog = new Views.QueryEditorWindow(Label, ValueText)
|
||||
{
|
||||
Owner = System.Windows.Application.Current.MainWindow,
|
||||
};
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
ValueText = dialog.QueryText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>색 행 — 레거시 invariant 문자열("R, G, B"/명명색) + 미리보기 스와치</summary>
|
||||
public sealed class ColorRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>미리보기 브러시</summary>
|
||||
public Brush Preview
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ValueText.Length == 0)
|
||||
{
|
||||
return Brushes.Transparent;
|
||||
}
|
||||
var (a, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(ValueText);
|
||||
var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>색상 피커 열기 — 확정 시 레거시 invariant 형식("R, G, B")으로 반영</summary>
|
||||
public M.Framework.WPF.ICustomCommand? PickCommand { get; set; }
|
||||
|
||||
public ColorRowViewModel(string label) : base(label)
|
||||
{
|
||||
PickCommand = new M.Framework.WPF.Command((sender, e) => OnPick());
|
||||
}
|
||||
|
||||
protected override void OnValueApplied() => OnPropertyChanged(nameof(Preview));
|
||||
|
||||
private void OnPick()
|
||||
{
|
||||
var initialHex = (string?)null;
|
||||
if (ValueText.Length > 0)
|
||||
{
|
||||
var (_, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(ValueText);
|
||||
initialHex = $"#{r:X2}{g:X2}{b:X2}";
|
||||
}
|
||||
var picked = Views.ColorPickerWindow.Pick(System.Windows.Application.Current.MainWindow, initialHex);
|
||||
if (picked is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var color = (Color)ColorConverter.ConvertFromString(picked);
|
||||
ValueText = Core.Serialization.LegacyFormat.FormatColor(255, color.R, color.G, color.B);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>읽기 전용 행 — 중첩/바이너리/참조 등 raw 편집 불가 값 표시</summary>
|
||||
public sealed class ReadOnlyRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
public ReadOnlyRowViewModel(string label, string display) : base(label)
|
||||
{
|
||||
Initialize(display);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>전체 속성(고급) 섹션 토글 행 — 표시/숨김 버튼</summary>
|
||||
public sealed class ToggleAdvancedRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>버튼 표시 텍스트</summary>
|
||||
public string ButtonText { get; }
|
||||
|
||||
/// <summary>토글 실행</summary>
|
||||
public M.Framework.WPF.ICustomCommand? ToggleCommand { get; set; }
|
||||
|
||||
public ToggleAdvancedRowViewModel(string buttonText, Action toggle) : base(string.Empty)
|
||||
{
|
||||
ButtonText = buttonText;
|
||||
ToggleCommand = new M.Framework.WPF.Command((sender, e) => toggle());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>속성 추가 행 — 키 입력 후 빈 속성 생성(고급)</summary>
|
||||
public sealed class AddPropertyRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
private string keyText = string.Empty;
|
||||
|
||||
/// <summary>추가할 속성 키(레거시 Property 이름)</summary>
|
||||
public string KeyText
|
||||
{
|
||||
get => keyText;
|
||||
set => SetProperty(ref keyText, value);
|
||||
}
|
||||
|
||||
/// <summary>추가 실행</summary>
|
||||
public M.Framework.WPF.ICustomCommand? AddCommand { get; set; }
|
||||
|
||||
public AddPropertyRowViewModel(Action<string> add) : base(string.Empty)
|
||||
{
|
||||
AddCommand = new M.Framework.WPF.Command((sender, e) =>
|
||||
{
|
||||
var key = KeyText.Trim();
|
||||
if (key.Length > 0)
|
||||
{
|
||||
add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>인스펙터 행 컨텍스트 — 대상 컨트롤 집합과 접근자</summary>
|
||||
public sealed class RowBinding
|
||||
{
|
||||
/// <summary>값 읽기</summary>
|
||||
public required Func<ControlViewModel, string?> Get { get; init; }
|
||||
|
||||
/// <summary>값 쓰기</summary>
|
||||
public required Action<ControlViewModel, string> Set { get; init; }
|
||||
|
||||
/// <summary>커밋 후 시각 재해석 필요 여부</summary>
|
||||
public bool AffectsVisual { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using M.Framework.WPF;
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Data.Stores;
|
||||
using SheetMe.Designer.DataBusiness;
|
||||
using SheetMe.Designer.Services;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 메인 셸 ViewModel — 멀티 문서 탭(여러 기록지 동시 편집), 상시 서식 목록 패널,
|
||||
/// 파일/DB 열기·저장, 도구(상용구/폰트). 컨트롤 클립보드는 문서 간 공유(서식 간 복사/붙여넣기).
|
||||
/// </summary>
|
||||
internal sealed class MainViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly FormDesignDataBusiness dataBusiness = new();
|
||||
private readonly DialogService dialogService = new();
|
||||
private DesignerViewModel? currentDesigner;
|
||||
private string title = "SheetMe 서식생성기";
|
||||
private string statusText = "준비";
|
||||
private string sheetSearchKeyword = string.Empty;
|
||||
private bool isSheetListLoading;
|
||||
private int selectedLeftTabIndex;
|
||||
private bool isHandTool;
|
||||
private const string XmlFilter = "서식 XML (*.xml)|*.xml|모든 파일 (*.*)|*.*";
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>창 제목</summary>
|
||||
public string Title
|
||||
{
|
||||
get => title;
|
||||
set => SetProperty(ref title, value);
|
||||
}
|
||||
|
||||
/// <summary>상태바 텍스트</summary>
|
||||
public string StatusText
|
||||
{
|
||||
get => statusText;
|
||||
set => SetProperty(ref statusText, value);
|
||||
}
|
||||
|
||||
/// <summary>열린 문서(탭) 목록</summary>
|
||||
public ObservableCollection<DesignerViewModel> OpenDesigners { get; } = new();
|
||||
|
||||
/// <summary>활성 문서(선택 탭)</summary>
|
||||
public DesignerViewModel? CurrentDesigner
|
||||
{
|
||||
get => currentDesigner;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref currentDesigner, value))
|
||||
{
|
||||
Title = value is null
|
||||
? "SheetMe 서식생성기"
|
||||
: $"SheetMe 서식생성기 — {value.DisplayName}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>팔레트 컨트롤 목록(레지스트리)</summary>
|
||||
public IReadOnlyList<ControlDescriptor> PaletteItems => ControlRegistry.All;
|
||||
|
||||
/// <summary>서식 목록(상시 패널) — DB E_ShtMst</summary>
|
||||
public ObservableCollection<SheetSummary> SheetList { get; } = new();
|
||||
|
||||
/// <summary>서식 목록 검색어</summary>
|
||||
public string SheetSearchKeyword
|
||||
{
|
||||
get => sheetSearchKeyword;
|
||||
set => SetProperty(ref sheetSearchKeyword, value);
|
||||
}
|
||||
|
||||
/// <summary>서식 목록 로딩 중</summary>
|
||||
public bool IsSheetListLoading
|
||||
{
|
||||
get => isSheetListLoading;
|
||||
set => SetProperty(ref isSheetListLoading, value);
|
||||
}
|
||||
|
||||
/// <summary>DB 사용 가능 여부(서식 목록 패널 안내용)</summary>
|
||||
public bool CanUseDb => dataBusiness.CanUseDb;
|
||||
|
||||
/// <summary>좌측 패널 탭(0=서식 목록, 1=레이어, 2=도구 상자) — 서식 열면 레이어로 자동 전환([200] 관행)</summary>
|
||||
public int SelectedLeftTabIndex
|
||||
{
|
||||
get => selectedLeftTabIndex;
|
||||
set => SetProperty(ref selectedLeftTabIndex, value);
|
||||
}
|
||||
|
||||
/// <summary>손(팬) 도구 활성 — 캔버스 드래그로 화면 이동(false=선택 도구, [200] 플로팅 바 관행)</summary>
|
||||
public bool IsHandTool
|
||||
{
|
||||
get => isHandTool;
|
||||
set => SetProperty(ref isHandTool, value);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public MainViewModel()
|
||||
{
|
||||
LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync());
|
||||
NewFileCommand = new Command((sender, e) => OnNewFile());
|
||||
OpenFileCommand = new Command((sender, e) => OnOpenFile());
|
||||
SaveFileCommand = new Command((sender, e) => OnSaveFile(saveAs: false));
|
||||
SaveAsFileCommand = new Command((sender, e) => OnSaveFile(saveAs: true));
|
||||
OpenFromDbCommand = new Command((sender, e) => OnOpenFromDb());
|
||||
SaveToDbCommand = new Command((sender, e) => OnSaveToDb());
|
||||
ExportJsonCommand = new Command((sender, e) => OnExportJson());
|
||||
ImportJsonCommand = new Command((sender, e) => OnImportJson());
|
||||
PreviewCommand = new Command((sender, e) => OnPreview());
|
||||
PrintCommand = new Command((sender, e) => OnPrint());
|
||||
RecordWordCommand = new Command((sender, e) => OnRecordWords());
|
||||
FontManagerCommand = new Command((sender, e) => OnFontManager());
|
||||
SheetHistoryCommand = new Command((sender, e) => OnSheetHistory());
|
||||
ZoomInCommand = new Command((sender, e) => CurrentDesigner?.ZoomIn());
|
||||
ZoomOutCommand = new Command((sender, e) => CurrentDesigner?.ZoomOut());
|
||||
ZoomResetCommand = new Command((sender, e) => CurrentDesigner?.ZoomReset());
|
||||
ExitCommand = new Command((sender, e) => Application.Current.Shutdown());
|
||||
SearchSheetsCommand = new Command(async (sender, e) => await LoadSheetListAsync());
|
||||
OpenSheetCommand = new Command((object param) => OnOpenSheetFromList(param as SheetSummary));
|
||||
CloseDocumentCommand = new Command((object param) => OnCloseDocument(param as DesignerViewModel));
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Commands
|
||||
/// <summary>창 로드</summary>
|
||||
public ICustomCommand? LoadedCommand { get; set; }
|
||||
|
||||
/// <summary>새 서식</summary>
|
||||
public ICustomCommand? NewFileCommand { get; set; }
|
||||
|
||||
/// <summary>서식 XML 열기</summary>
|
||||
public ICustomCommand? OpenFileCommand { get; set; }
|
||||
|
||||
/// <summary>저장</summary>
|
||||
public ICustomCommand? SaveFileCommand { get; set; }
|
||||
|
||||
/// <summary>다른 이름으로 저장</summary>
|
||||
public ICustomCommand? SaveAsFileCommand { get; set; }
|
||||
|
||||
/// <summary>DB에서 서식 열기(검색 대화상자)</summary>
|
||||
public ICustomCommand? OpenFromDbCommand { get; set; }
|
||||
|
||||
/// <summary>DB에 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — SaveMode 게이트</summary>
|
||||
public ICustomCommand? SaveToDbCommand { get; set; }
|
||||
|
||||
/// <summary>JSON 내보내기</summary>
|
||||
public ICustomCommand? ExportJsonCommand { get; set; }
|
||||
|
||||
/// <summary>JSON 가져오기</summary>
|
||||
public ICustomCommand? ImportJsonCommand { get; set; }
|
||||
|
||||
/// <summary>미리보기</summary>
|
||||
public ICustomCommand? PreviewCommand { get; set; }
|
||||
|
||||
/// <summary>인쇄</summary>
|
||||
public ICustomCommand? PrintCommand { get; set; }
|
||||
|
||||
/// <summary>상용구 관리</summary>
|
||||
public ICustomCommand? RecordWordCommand { get; set; }
|
||||
|
||||
/// <summary>폰트 일괄 변경</summary>
|
||||
public ICustomCommand? FontManagerCommand { get; set; }
|
||||
|
||||
/// <summary>서식 수정이력(버전 열람/복원)</summary>
|
||||
public ICustomCommand? SheetHistoryCommand { get; set; }
|
||||
|
||||
/// <summary>줌 확대</summary>
|
||||
public ICustomCommand? ZoomInCommand { get; set; }
|
||||
|
||||
/// <summary>줌 축소</summary>
|
||||
public ICustomCommand? ZoomOutCommand { get; set; }
|
||||
|
||||
/// <summary>줌 100%</summary>
|
||||
public ICustomCommand? ZoomResetCommand { get; set; }
|
||||
|
||||
/// <summary>종료</summary>
|
||||
public ICustomCommand? ExitCommand { get; set; }
|
||||
|
||||
/// <summary>서식 목록 검색</summary>
|
||||
public ICustomCommand? SearchSheetsCommand { get; set; }
|
||||
|
||||
/// <summary>서식 목록에서 열기(더블클릭)</summary>
|
||||
public ICustomCommand? OpenSheetCommand { get; set; }
|
||||
|
||||
/// <summary>문서 탭 닫기</summary>
|
||||
public ICustomCommand? CloseDocumentCommand { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Methods - 문서 탭
|
||||
/// <summary>문서를 탭으로 추가하고 활성화 — 좌측 패널은 레이어 탭으로 전환</summary>
|
||||
private void AttachDocument(DesignerViewModel designer)
|
||||
{
|
||||
OpenDesigners.Add(designer);
|
||||
CurrentDesigner = designer;
|
||||
SelectedLeftTabIndex = 1;
|
||||
}
|
||||
|
||||
private void OnCloseDocument(DesignerViewModel? designer)
|
||||
{
|
||||
designer ??= CurrentDesigner;
|
||||
if (designer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (designer.Undo.CanUndo
|
||||
&& MessageBox.Show($"'{designer.DisplayName}' 문서에 저장하지 않은 변경이 있을 수 있습니다.\n닫을까요?",
|
||||
"문서 닫기", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var index = OpenDesigners.IndexOf(designer);
|
||||
OpenDesigners.Remove(designer);
|
||||
if (CurrentDesigner == designer)
|
||||
{
|
||||
CurrentDesigner = OpenDesigners.Count > 0
|
||||
? OpenDesigners[Math.Clamp(index, 0, OpenDesigners.Count - 1)]
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
|
||||
private DesignerViewModel? FindOpenDbDocument(string shtCod)
|
||||
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod);
|
||||
#endregion
|
||||
|
||||
#region Methods - 열기/저장
|
||||
private async Task OnLoadedAsync()
|
||||
{
|
||||
OnNewFile();
|
||||
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
|
||||
await LoadSheetListAsync();
|
||||
}
|
||||
|
||||
private void OnNewFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
|
||||
StatusText = "새 서식 (720×856)";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = dialogService.ShowOpenFile(XmlFilter, "서식 XML 열기");
|
||||
if (path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var document = dataBusiness.OpenXmlFile(path);
|
||||
AttachDocument(new DesignerViewModel(document) { FilePath = path });
|
||||
StatusText = $"파일 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
|
||||
ShowReadWarnings(document.Meta.ReadWarnings, "서식 열기");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"서식을 여는 중 오류가 발생했습니다.\n\n{ex}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSaveFile(bool saveAs)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var path = CurrentDesigner.FilePath;
|
||||
if (saveAs || path is null)
|
||||
{
|
||||
path = dialogService.ShowSaveFile(XmlFilter, "서식 XML 저장",
|
||||
CurrentDesigner.Document.FormId + ".xml");
|
||||
if (path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dataBusiness.SaveXmlFile(CurrentDesigner.Document, path);
|
||||
CurrentDesigner.FilePath = path;
|
||||
CurrentDesigner.NotifyDisplayNameChanged();
|
||||
Title = $"SheetMe 서식생성기 — {CurrentDesigner.DisplayName}";
|
||||
StatusText = $"저장됨: {path}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"저장 중 오류가 발생했습니다.\n\n{ex}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenFromDb()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!EnsureDb())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var dialog = new Views.SheetOpenDialogView(keyword => dataBusiness.ListSheets(keyword))
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
};
|
||||
if (dialog.ShowDialog() != true || dialog.SelectedSheet is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
OpenDbSheet(dialog.SelectedSheet.ShtCod);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"DB에서 서식을 여는 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화</summary>
|
||||
private void OpenDbSheet(string shtCod)
|
||||
{
|
||||
var existing = FindOpenDbDocument(shtCod);
|
||||
if (existing is not null)
|
||||
{
|
||||
CurrentDesigner = existing;
|
||||
StatusText = $"이미 열린 서식: {shtCod}";
|
||||
return;
|
||||
}
|
||||
|
||||
var document = dataBusiness.OpenFromDb(shtCod);
|
||||
if (document is null)
|
||||
{
|
||||
MessageBox.Show("활성 디자인을 찾지 못했습니다.", "DB 열기",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
AttachDocument(new DesignerViewModel(document, dataBusiness.LoadSpreadGrids(shtCod)) { IsFromDb = true });
|
||||
StatusText = $"DB 로드: {document.FormId} (SdgKey {document.Meta.SourceSdgKey}) · " +
|
||||
$"페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
|
||||
ShowReadWarnings(document.Meta.ReadWarnings, "DB 열기");
|
||||
}
|
||||
|
||||
private void OnSaveToDb()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!dataBusiness.CanSaveToDb)
|
||||
{
|
||||
MessageBox.Show("DB 저장이 비활성화되어 있습니다.\nappsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정한 뒤 사용하세요.\n(운영 레거시 테이블에 기록되므로 명시적 활성화가 필요합니다)",
|
||||
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var document = CurrentDesigner.Document;
|
||||
|
||||
// 미등록 서식이면 신규 등록 다이얼로그
|
||||
if (!dataBusiness.SheetExists(document.FormId))
|
||||
{
|
||||
var register = new Views.RegisterSheetDialogView(
|
||||
document.FormId == "NewSheet" ? string.Empty : document.FormId,
|
||||
document.Title == "새 서식" ? string.Empty : document.Title)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
};
|
||||
if (register.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
dataBusiness.RegisterSheet(register.SheetCode, register.SheetName, register.ClassCode);
|
||||
document.FormId = register.SheetCode;
|
||||
document.Title = register.SheetName;
|
||||
}
|
||||
|
||||
var confirm = MessageBox.Show(
|
||||
$"서식 [{document.FormId}] {document.Title} 을(를) DB(E_SdgMst/E_SctMst)에 저장할까요?\n\n" +
|
||||
"기존 활성 디자인은 이력(SdgDelYon='Y')으로 보존되고 새 버전이 생성됩니다.\n" +
|
||||
"(제자리 갱신 서식(ShtCneYon='Y')은 기존 버전이 갱신됩니다)",
|
||||
"DB 저장 확인", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
if (confirm != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sdgKey = dataBusiness.SaveToDb(document);
|
||||
CurrentDesigner.IsFromDb = true;
|
||||
CurrentDesigner.NotifyDisplayNameChanged();
|
||||
StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}";
|
||||
MessageBox.Show($"저장되었습니다. (SdgKey {sdgKey})\n레거시 뷰어/디자이너에서 열어 확인하세요.", "DB 저장",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"DB 저장 중 오류가 발생했습니다. (트랜잭션 롤백됨)\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExportJson()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var path = dialogService.ShowSaveFile("서식 JSON (*.json)|*.json", "JSON 내보내기",
|
||||
CurrentDesigner.Document.FormId + ".json");
|
||||
if (path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var json = new Core.Serialization.FormJsonSerializer().Write(CurrentDesigner.Document);
|
||||
File.WriteAllText(path, json, System.Text.Encoding.UTF8);
|
||||
StatusText = $"JSON 내보내기 완료: {path}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"JSON 내보내기 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnImportJson()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = dialogService.ShowOpenFile("서식 JSON (*.json)|*.json", "JSON 가져오기");
|
||||
if (path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var document = new Core.Serialization.FormJsonSerializer().Read(File.ReadAllText(path));
|
||||
if (document.FormId.Length == 0)
|
||||
{
|
||||
document.FormId = Path.GetFileNameWithoutExtension(path);
|
||||
}
|
||||
AttachDocument(new DesignerViewModel(document));
|
||||
StatusText = $"JSON 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"JSON 가져오기 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - 서식 목록 패널
|
||||
/// <summary>서식 목록 로드(비동기) — DB 미접속이면 건너뜀</summary>
|
||||
private async Task LoadSheetListAsync()
|
||||
{
|
||||
if (!dataBusiness.CanUseDb || IsSheetListLoading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
IsSheetListLoading = true;
|
||||
var keyword = SheetSearchKeyword;
|
||||
var sheets = await Task.Run(() => dataBusiness.ListSheets(keyword));
|
||||
SheetList.Clear();
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
SheetList.Add(sheet);
|
||||
}
|
||||
StatusText = $"서식 목록 {sheets.Count}건";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"서식 목록 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "서식 목록",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSheetListLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서식 목록에서 열기 — 뷰 더블클릭 핸들러가 위임 호출</summary>
|
||||
public void OpenSheetFromList(SheetSummary? sheet) => OnOpenSheetFromList(sheet);
|
||||
|
||||
private void OnOpenSheetFromList(SheetSummary? sheet)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (sheet is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!sheet.HasDesign)
|
||||
{
|
||||
MessageBox.Show("선택한 서식에는 저장된 디자인이 없습니다.", "서식 열기",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
OpenDbSheet(sheet.ShtCod);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"서식을 여는 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - 도구
|
||||
private void OnPreview()
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
new Views.PreviewWindow(CurrentDesigner)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
}.Show();
|
||||
}
|
||||
|
||||
private void OnPrint()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Services.PrintService.Print(CurrentDesigner, CurrentDesigner.Document.Title.Length > 0
|
||||
? CurrentDesigner.Document.Title
|
||||
: CurrentDesigner.Document.FormId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"인쇄 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFontManager()
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
new Views.FontManagerDialogView(CurrentDesigner)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
}.ShowDialog();
|
||||
}
|
||||
|
||||
private void OnSheetHistory()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null || !EnsureDb())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var document = CurrentDesigner.Document;
|
||||
if (document.FormId.Length == 0 || document.FormId == "NewSheet")
|
||||
{
|
||||
MessageBox.Show("수정이력은 DB에 저장된 서식에서 사용할 수 있습니다.", "서식 수정이력",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var versions = dataBusiness.ListVersions(document.FormId);
|
||||
if (versions.Count == 0)
|
||||
{
|
||||
MessageBox.Show("저장된 버전이 없습니다.", "서식 수정이력",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = new Views.SheetHistoryDialogView(document.FormId, document.Title, versions)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
};
|
||||
if (dialog.ShowDialog() != true || dialog.SelectedSdgKey is not { } sdgKey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 열람 중인 동일 버전 탭이면 활성화
|
||||
var existing = OpenDesigners.FirstOrDefault(d => d.HistorySdgKey == sdgKey
|
||||
&& d.Document.FormId == document.FormId);
|
||||
if (existing is not null)
|
||||
{
|
||||
CurrentDesigner = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
var versionDocument = dataBusiness.OpenFromDbVersion(document.FormId, sdgKey);
|
||||
if (versionDocument is null)
|
||||
{
|
||||
MessageBox.Show("해당 버전을 불러오지 못했습니다.", "서식 수정이력",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
AttachDocument(new DesignerViewModel(versionDocument, dataBusiness.LoadSpreadGrids(document.FormId))
|
||||
{
|
||||
IsFromDb = true,
|
||||
HistorySdgKey = sdgKey,
|
||||
});
|
||||
StatusText = $"이력 열람: {document.FormId} SdgKey {sdgKey} — 'DB에 저장' 시 이 내용이 새 활성 버전이 됩니다";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"수정이력 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRecordWords()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (CurrentDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!EnsureDb())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var document = CurrentDesigner.Document;
|
||||
if (document.FormId.Length == 0 || document.FormId == "NewSheet")
|
||||
{
|
||||
MessageBox.Show("상용구는 서식 코드 단위로 저장됩니다.\n먼저 DB에 저장(서식 등록)한 뒤 사용하세요.",
|
||||
"상용구 관리", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
new Views.RecordWordDialogView(dataBusiness.RecordWords(), document.FormId, document.Title)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
}.ShowDialog();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"상용구 관리 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Private
|
||||
private bool EnsureDb()
|
||||
{
|
||||
if (dataBusiness.CanUseDb)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
MessageBox.Show("DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.\nappsettings.json 을 확인하세요.",
|
||||
"DB 연결", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ShowReadWarnings(List<string> warnings, string caption)
|
||||
{
|
||||
if (warnings.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var summary = string.Join("\n", warnings.Take(20));
|
||||
var more = warnings.Count > 20 ? $"\n... 외 {warnings.Count - 20}건" : string.Empty;
|
||||
MessageBox.Show($"읽기 경고 {warnings.Count}건 (미지원 컨트롤은 자리표시로 보존됩니다):\n\n{summary}{more}",
|
||||
caption, MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
|
||||
private static int CountControls(List<Core.Models.ControlElement> controls)
|
||||
=> controls.Sum(c => 1 + CountControls(c.Children));
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>페이지 ViewModel — 용지 1장의 크기·배경·컨트롤(그리기 순서) 투영.</summary>
|
||||
public sealed class PageViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
private double offsetY;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>페이지 모델</summary>
|
||||
public FormPage Model { get; }
|
||||
|
||||
/// <summary>페이지 번호(0-base)</summary>
|
||||
public int Index { get; }
|
||||
|
||||
/// <summary>페이지 컨트롤(그리기 순서 — 마지막이 최상위)</summary>
|
||||
public ObservableCollection<ControlViewModel> Controls { get; } = new();
|
||||
|
||||
/// <summary>용지 너비(DIP)</summary>
|
||||
public double WidthDip => Model.Width;
|
||||
|
||||
/// <summary>용지 높이(DIP)</summary>
|
||||
public double HeightDip => Model.Height;
|
||||
|
||||
/// <summary>월드 좌표 세로 오프셋 — DesignerViewModel 이 페이지 스택 계산</summary>
|
||||
public double OffsetY
|
||||
{
|
||||
get => offsetY;
|
||||
set => SetProperty(ref offsetY, value);
|
||||
}
|
||||
|
||||
/// <summary>용지 배경 브러시 — 루트 BackColor(기본 White)</summary>
|
||||
public Brush PaperBrush
|
||||
{
|
||||
get
|
||||
{
|
||||
var backColor = Model.Root.Props.GetText("BackColor");
|
||||
if (backColor is null)
|
||||
{
|
||||
return Brushes.White;
|
||||
}
|
||||
var (a, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(backColor);
|
||||
var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PageViewModel(FormPage model, int index)
|
||||
{
|
||||
Model = model;
|
||||
Index = index;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>용지 크기 변경 통지</summary>
|
||||
public void NotifySizeChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(WidthDip));
|
||||
OnPropertyChanged(nameof(HeightDip));
|
||||
OnPropertyChanged(nameof(SizeText));
|
||||
}
|
||||
|
||||
/// <summary>페이지 패널 표시 텍스트</summary>
|
||||
public string SizeText => $"{WidthDip:0}×{HeightDip:0}";
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>오버레이 가이드선 1개 — 월드 좌표 수직/수평선</summary>
|
||||
public sealed class GuideLineInfo
|
||||
{
|
||||
/// <summary>수직선 여부(false=수평선)</summary>
|
||||
public bool IsVertical { get; init; }
|
||||
|
||||
/// <summary>선 위치(월드 X 또는 Y)</summary>
|
||||
public double Position { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
|
||||
public sealed class TabBadgeInfo
|
||||
{
|
||||
/// <summary>배지 X(월드)</summary>
|
||||
public double X { get; init; }
|
||||
|
||||
/// <summary>배지 Y(월드)</summary>
|
||||
public double Y { get; init; }
|
||||
|
||||
/// <summary>표시 텍스트(순번 또는 "–")</summary>
|
||||
public string Text { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>순번 지정 여부(지정=파랑, 미지정=회색)</summary>
|
||||
public bool IsAssigned { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>선택 핸들 1개 — 월드 좌표(중심점)</summary>
|
||||
public sealed class HandleInfo
|
||||
{
|
||||
/// <summary>핸들 인덱스(0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)</summary>
|
||||
public int Index { get; init; }
|
||||
|
||||
/// <summary>핸들 좌상단 X(월드)</summary>
|
||||
public double X { get; init; }
|
||||
|
||||
/// <summary>핸들 좌상단 Y(월드)</summary>
|
||||
public double Y { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택/마퀴/가이드 오버레이 표시 상태 — 전부 월드 좌표.
|
||||
/// 오버레이 뷰는 이 상태를 그리기만 한다(입력 처리 없음).
|
||||
/// </summary>
|
||||
public sealed class SelectionOverlayViewModel : ViewModelBase
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>핸들 한 변 크기(논리 px)</summary>
|
||||
public const double HandleSize = 8;
|
||||
|
||||
private bool hasSelection;
|
||||
private double selX;
|
||||
private double selY;
|
||||
private double selW;
|
||||
private double selH;
|
||||
private bool showHandles;
|
||||
private bool hasMarquee;
|
||||
private double marX;
|
||||
private double marY;
|
||||
private double marW;
|
||||
private double marH;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택 박스 표시 여부</summary>
|
||||
public bool HasSelection { get => hasSelection; set => SetProperty(ref hasSelection, value); }
|
||||
|
||||
/// <summary>선택 박스 X(월드)</summary>
|
||||
public double SelX { get => selX; set => SetProperty(ref selX, value); }
|
||||
|
||||
/// <summary>선택 박스 Y(월드)</summary>
|
||||
public double SelY { get => selY; set => SetProperty(ref selY, value); }
|
||||
|
||||
/// <summary>선택 박스 너비</summary>
|
||||
public double SelW { get => selW; set => SetProperty(ref selW, value); }
|
||||
|
||||
/// <summary>선택 박스 높이</summary>
|
||||
public double SelH { get => selH; set => SetProperty(ref selH, value); }
|
||||
|
||||
/// <summary>리사이즈 핸들 표시 여부(잠금 선택 시 숨김)</summary>
|
||||
public bool ShowHandles { get => showHandles; set => SetProperty(ref showHandles, value); }
|
||||
|
||||
/// <summary>핸들 8개(월드 좌표)</summary>
|
||||
public ObservableCollection<HandleInfo> Handles { get; } = new();
|
||||
|
||||
/// <summary>마퀴 표시 여부</summary>
|
||||
public bool HasMarquee { get => hasMarquee; set => SetProperty(ref hasMarquee, value); }
|
||||
|
||||
/// <summary>마퀴 X(월드)</summary>
|
||||
public double MarX { get => marX; set => SetProperty(ref marX, value); }
|
||||
|
||||
/// <summary>마퀴 Y(월드)</summary>
|
||||
public double MarY { get => marY; set => SetProperty(ref marY, value); }
|
||||
|
||||
/// <summary>마퀴 너비</summary>
|
||||
public double MarW { get => marW; set => SetProperty(ref marW, value); }
|
||||
|
||||
/// <summary>마퀴 높이</summary>
|
||||
public double MarH { get => marH; set => SetProperty(ref marH, value); }
|
||||
|
||||
/// <summary>정렬 가이드선 목록</summary>
|
||||
public ObservableCollection<GuideLineInfo> Guides { get; } = new();
|
||||
|
||||
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
|
||||
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>선택 박스/핸들 갱신 — bbox 는 월드 좌표</summary>
|
||||
public void UpdateSelection(Rect? bbox, bool handlesVisible)
|
||||
{
|
||||
if (bbox is null || bbox.Value.IsEmpty)
|
||||
{
|
||||
HasSelection = false;
|
||||
ShowHandles = false;
|
||||
Handles.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = bbox.Value;
|
||||
HasSelection = true;
|
||||
SelX = rect.X;
|
||||
SelY = rect.Y;
|
||||
SelW = rect.Width;
|
||||
SelH = rect.Height;
|
||||
ShowHandles = handlesVisible;
|
||||
|
||||
Handles.Clear();
|
||||
if (!handlesVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var half = HandleSize / 2;
|
||||
var positions = HandlePositions(rect);
|
||||
for (var i = 0; i < positions.Length; i++)
|
||||
{
|
||||
Handles.Add(new HandleInfo { Index = i, X = positions[i].X - half, Y = positions[i].Y - half });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>핸들 중심 좌표 8개(0=NW 시계방향)</summary>
|
||||
public static Point[] HandlePositions(Rect rect) => new[]
|
||||
{
|
||||
new Point(rect.Left, rect.Top),
|
||||
new Point(rect.Left + rect.Width / 2, rect.Top),
|
||||
new Point(rect.Right, rect.Top),
|
||||
new Point(rect.Right, rect.Top + rect.Height / 2),
|
||||
new Point(rect.Right, rect.Bottom),
|
||||
new Point(rect.Left + rect.Width / 2, rect.Bottom),
|
||||
new Point(rect.Left, rect.Bottom),
|
||||
new Point(rect.Left, rect.Top + rect.Height / 2),
|
||||
};
|
||||
|
||||
/// <summary>마퀴 갱신 — null 이면 숨김</summary>
|
||||
public void UpdateMarquee(Rect? rect)
|
||||
{
|
||||
if (rect is null)
|
||||
{
|
||||
HasMarquee = false;
|
||||
return;
|
||||
}
|
||||
HasMarquee = true;
|
||||
MarX = rect.Value.X;
|
||||
MarY = rect.Value.Y;
|
||||
MarW = rect.Value.Width;
|
||||
MarH = rect.Value.Height;
|
||||
}
|
||||
|
||||
/// <summary>가이드선 교체</summary>
|
||||
public void SetGuides(double? guideX, double? guideY)
|
||||
{
|
||||
Guides.Clear();
|
||||
if (guideX is not null)
|
||||
{
|
||||
Guides.Add(new GuideLineInfo { IsVertical = true, Position = guideX.Value });
|
||||
}
|
||||
if (guideY is not null)
|
||||
{
|
||||
Guides.Add(new GuideLineInfo { IsVertical = false, Position = guideY.Value });
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using M.Framework.WPF;
|
||||
|
||||
namespace SheetMe.Designer.ViewModels;
|
||||
|
||||
/// <summary>프로젝트 공통 ViewModel 베이스 — BindableBase + 비동기 정리 지원.</summary>
|
||||
public abstract class ViewModelBase : BindableBase, IAsyncDisposable
|
||||
{
|
||||
#region Dispose
|
||||
/// <summary>비동기 정리</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisposeAsyncCore();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>파생 클래스 정리 지점</summary>
|
||||
protected virtual ValueTask DisposeAsyncCore() => ValueTask.CompletedTask;
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user