초기 커밋: 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:
+13
@@ -0,0 +1,13 @@
|
||||
bin/
|
||||
obj/
|
||||
publish/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
# 실접속 정보 — 절대 커밋하지 않는다. 배포 산출물에도 포함되지 않는다(csproj Debug 조건부 복사).
|
||||
appsettings.Development.json
|
||||
|
||||
# 진단 리포트 산출물
|
||||
*-smoke.txt
|
||||
baseline.txt
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project>
|
||||
|
||||
<!-- SheetMe 공통 빌드 속성 — 전 프로젝트 적용 -->
|
||||
<PropertyGroup>
|
||||
<Company>MSYSTECH</Company>
|
||||
<Product>SheetMe (서식생성기)</Product>
|
||||
<Copyright>Copyright © $([System.DateTime]::Now.Year) Msystech. All rights reserved.</Copyright>
|
||||
<Version>1.0.0.0</Version>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,72 @@
|
||||
# SheetMe 서식생성기 (C#/.NET 10 WPF)
|
||||
|
||||
MsystechHIS 레거시 서식생성기(`[003]EMR\[001]SheetDesigner`, VB.NET WinForms)를 대체하는 **신규 WPF 서식 디자이너**.
|
||||
레거시 XML 포맷을 **읽기·쓰기 모두 무손실 호환**하여, 여기서 만든 서식을 기존 EMR 런타임(EMRLoader)이 그대로 렌더링한다.
|
||||
|
||||
## 프로젝트 구성
|
||||
|
||||
| 프로젝트 | TFM | 역할 |
|
||||
|---|---|---|
|
||||
| `src/SheetMe.Core` | net10.0 | 포맷 중립 모델(FormDocument) · **LegacyXmlSerializer**(읽기+쓰기, PropBag 무손실 왕복) · FormJsonSerializer(부 포맷) · ControlRegistry(팔레트/속성 스키마) |
|
||||
| `src/SheetMe.Data` | net10.0 | Oracle 저장소 — **OracleLegacyFormStore**(E_ShtMst/E_SdgMst/E_SctMst, 레거시 동일 저장 의미론) · SctMstXmlWalker · EmrDataContext(M.MW.Data.EMR) |
|
||||
| `src/SheetMe.Designer` | net10.0-windows | WPF MVVM 디자이너(M.Framework.WPF) — 캔버스/팔레트/인스펙터/페이지/레이어 |
|
||||
| `tests/SheetMe.Core.Tests` | net10.0 | MSTest 44건 — 실서식 XML 왕복 의미론 diff 0 게이트 포함 |
|
||||
|
||||
## 실행
|
||||
|
||||
```powershell
|
||||
dotnet build
|
||||
dotnet run --project src/SheetMe.Designer
|
||||
dotnet test
|
||||
```
|
||||
|
||||
### 진단 모드 (자동 검증)
|
||||
```powershell
|
||||
# 첫 페이지 오프스크린 렌더 → PNG (레거시 시각 대조용)
|
||||
SheetMe.Designer.exe --render-smoke <서식.xml> <출력.png>
|
||||
# 편집 코어 자동 검증(선택/이동/리사이즈/마퀴/복붙/Undo/왕복) → 리포트
|
||||
SheetMe.Designer.exe --edit-smoke <리포트.txt>
|
||||
```
|
||||
|
||||
## 주요 기능 (v1)
|
||||
|
||||
- **캔버스 편집**: 클릭/Ctrl/Shift/마퀴 선택, 드래그 이동(스냅: 그리드+정렬 가이드, Alt=자유), 8핸들 리사이즈(다중선택 비례), 키보드 넛지, z-order, 정렬 6종
|
||||
- **Undo/Redo**: 문서 스냅샷 메멘토 — 드래그=1스텝, 넛지 400ms 코얼레스
|
||||
- **팔레트 14종**: Label·TextBox·MaskedTextBox·CheckBox·RadioButton·ComboBox·ListBox·CheckList·DateTimePicker·Panel·GroupBox·Line·PictureBox·CalcBox — 미지원 레거시 컨트롤(Spread/MSign 등)은 **자리표시로 무손실 보존**(이동/리사이즈 가능, 저장 시 원본 속성 전부 유지)
|
||||
- **인스펙터**: 스키마 기반 속성 편집(다중선택 교집합), 폰트/색은 레거시 invariant 문자열 정합
|
||||
- **페이지/레이어**: 페이지 추가/삭제/용지 크기, 레이어 목록(잠금/숨김)
|
||||
- **그룹**: Ctrl+G/Ctrl+Shift+G — 그룹 멤버 클릭 시 그룹 전체가 한 단위로 선택/이동
|
||||
- **컨테이너 자식 편집**: Panel/GroupBox 내부 컨트롤을 캔버스에서 직접 클릭·이동·리사이즈·삭제(상대좌표 유지)
|
||||
- **미리보기/인쇄**: 편집 크롬 없는 렌더(캔버스와 동일 템플릿), FixedPage 1:1(DIP) 인쇄
|
||||
- **파일**: 레거시 .xml 열기/저장(TK_ExportXML 호환) + JSON 가져오기/내보내기(부 포맷)
|
||||
- **DB**: E_ShtMst 검색 → 활성 디자인 열기 · **DB 저장**(레거시 동일: ShtCneYon='Y'→제자리 갱신 / 그 외 SdgDelYon 버저닝 + E_SctMst 재생성, 시퀀스→MAX+1 채번, 단일 트랜잭션) · **신규 서식 등록**(미등록 코드 저장 시 자동 안내 — ShtTyp='D' 관행 기본값)
|
||||
|
||||
## 설정 (`src/SheetMe.Designer/appsettings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": { "His": "Oracle 접속 문자열" },
|
||||
"FormStore": { "SaveMode": "File" } // DB 저장은 "Db" 로 명시 변경 시에만 활성(안전 게이트)
|
||||
}
|
||||
```
|
||||
|
||||
## 레거시 호환 규약 (정합성 기준: bzDesignSheetLoader.LoadObject 가 오류 없이 복원)
|
||||
|
||||
- XML: `<Sheet name="SheetDesignInfo">` 루트에 페이지당 `<Object>`(MDesignerHost) N개, 자식 Object 먼저 → Property 나중
|
||||
- 타입: 레거시 AQN 원문 보존, 신규는 `M.EMR.UserControl.{Cls}, M.EMR.UserControl, Version=1.0.0.0, ...`
|
||||
- 속성값: WinForms TypeConverter invariant 포맷(`"120, 88"`, `"굴림, 11.25pt, style=Bold"`, `"224, 224, 224"`)
|
||||
- LocationOnBase 는 저장 시 재계산(부모 절대좌표 누적), `<Binary>` 는 verbatim 보존(신규 생성 안 함 — .NET 10 BinaryFormatter 제거)
|
||||
- z-order: XML 첫 Object = 최상위(WinForms Controls 규약) ↔ WPF 캔버스는 역순 렌더
|
||||
|
||||
## 실DB 검증 결과 (2026-07-16, 테스트DB 192.168.100.222/MSYSTECHHIS)
|
||||
|
||||
- **읽기**: 전체 디자인 서식 1,271건 일괄 왕복(Read→Write) **의미론 diff 0 / 예외 0** — 미지원 컨트롤 보존 서식 242건 포함
|
||||
- **쓰기**: S999 무변경 재저장 — 신규 버전 생성(SdgKey 52241→52380), 재저장본 diff 0, E_SctMst 36행 워커 기대치 일치, 이전 버전 `SdgDelYon='Y'` 이력 보존 확인
|
||||
- 호환성 세부: 텍스트 내 CR 은 `NewLineHandling.Entitize`(
 보존), 레거시 데이터의 제어문자(0x05)는 `CheckCharacters=false` 관용 파싱(구식 XmlTextReader 동등)
|
||||
|
||||
## 계획 범위(P0~P5) 전체 완료 — 후속 아이디어
|
||||
|
||||
- 레거시 SheetDesigner/EMR 미리보기로 S999 육안 확인 (권장)
|
||||
- v2 후보: 미지원 컨트롤 네이티브 지원(Spread 표, MSign 서명), 계산박스 수식 평가, 컨트롤 간 자식 드래그 재배치(reparent), 자 눈금(룰러), REST API 경유 모드
|
||||
|
||||
설계 계획서: `C:\Users\Msystech\.claude\plans\c-net-wpf-flickering-gizmo.md`
|
||||
@@ -0,0 +1,10 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/SheetMe.Core/SheetMe.Core.csproj" />
|
||||
<Project Path="src/SheetMe.Data/SheetMe.Data.csproj" />
|
||||
<Project Path="src/SheetMe.Designer/SheetMe.Designer.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/SheetMe.Core.Tests/SheetMe.Core.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,258 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 팔레트 컨트롤 카탈로그 — 팔레트 목록·기본 크기·필드 생성 규칙의 단일 출처.
|
||||
/// 속성 편집 스키마(PropertyDef)는 P3(인스펙터)에서 확장한다.
|
||||
/// </summary>
|
||||
public static class ControlRegistry
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly List<ControlDescriptor> all = new()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = "Label", DisplayName = "라벨", DefaultW = 120, DefaultH = 22,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.MultilineText },
|
||||
new() { Key = "TextAlign", Label = "정렬", Editor = PropEditorKind.Choice, Choices = new[] { "TopLeft", "TopCenter", "TopRight", "MiddleLeft", "MiddleCenter", "MiddleRight" } },
|
||||
new() { Key = "ForeColor", Label = "글자색", Editor = PropEditorKind.Color },
|
||||
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "TextBox", DisplayName = "텍스트박스", DefaultW = 160, DefaultH = 22, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
|
||||
new() { Key = "Multiline", Label = "여러 줄", Editor = PropEditorKind.Toggle },
|
||||
new() { Key = "TextAlign", Label = "정렬", Editor = PropEditorKind.Choice, Choices = new[] { "Left", "Center", "Right" } },
|
||||
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
|
||||
new() { Key = "InitialValue", Label = "초기값", Editor = PropEditorKind.Text },
|
||||
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
|
||||
new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag },
|
||||
new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text },
|
||||
new() { Key = "RwdRsvWrdYon", Label = "상용구 사용", Editor = PropEditorKind.Toggle },
|
||||
new() { Key = "RwdOrderAutYon", Label = "처방연동 상용구", Editor = PropEditorKind.Toggle },
|
||||
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
|
||||
new() { Key = "BorderStyle", Label = "테두리", Editor = PropEditorKind.Choice, Choices = new[] { "None", "FixedSingle", "Fixed3D" } },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "MaskedTextBox", DisplayName = "마스크입력", DefaultW = 160, DefaultH = 22, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Text },
|
||||
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
|
||||
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "CheckBox", DisplayName = "체크박스", DefaultW = 110, DefaultH = 20, CreatesField = true, DefaultDataType = "bool",
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
|
||||
new() { Key = "Checked", Label = "기본 체크", Editor = PropEditorKind.Toggle },
|
||||
new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number },
|
||||
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "RadioButton", DisplayName = "라디오버튼", DefaultW = 110, DefaultH = 20, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
|
||||
new() { Key = "Checked", Label = "기본 선택", Editor = PropEditorKind.Toggle },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "ComboBox", DisplayName = "콤보박스", DefaultW = 140, DefaultH = 22, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Items", Label = "선택지(줄바꿈 구분)", Editor = PropEditorKind.StringList },
|
||||
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
|
||||
new() { Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.Text },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "ListBox", DisplayName = "리스트박스", DefaultW = 140, DefaultH = 90, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Items", Label = "항목(줄바꿈 구분)", Editor = PropEditorKind.StringList },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "CheckList", DisplayName = "체크리스트", DefaultW = 160, DefaultH = 90, CreatesField = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Items", Label = "항목(줄바꿈 구분)", Editor = PropEditorKind.StringList },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "DateTimePicker", DisplayName = "날짜선택", DefaultW = 140, DefaultH = 22, CreatesField = true, DefaultDataType = "datetime",
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Format", Label = "형식", Editor = PropEditorKind.Choice, Choices = new[] { "Long", "Short", "Time", "Custom" } },
|
||||
new() { Key = "CustomFormat", Label = "사용자 형식", Editor = PropEditorKind.Text },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "Panel", DisplayName = "패널", DefaultW = 240, DefaultH = 140, IsContainer = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
|
||||
new() { Key = "BorderStyle", Label = "테두리", Editor = PropEditorKind.Choice, Choices = new[] { "None", "FixedSingle", "Fixed3D" } },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "GroupBox", DisplayName = "그룹박스", DefaultW = 240, DefaultH = 140, IsContainer = true,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "제목", Editor = PropEditorKind.Text },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "Line", DisplayName = "선", DefaultW = 200, DefaultH = 1,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "LineColor", Label = "선 색", Editor = PropEditorKind.Color },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "PictureBox", DisplayName = "이미지", DefaultW = 120, DefaultH = 90,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
|
||||
new() { Key = "SizeMode", Label = "크기 모드", Editor = PropEditorKind.Choice, Choices = new[] { "Normal", "StretchImage", "AutoSize", "CenterImage", "Zoom" } },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "CalcBox", DisplayName = "계산박스", DefaultW = 100, DefaultH = 22, CreatesField = true, DefaultDataType = "decimal",
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Formula", Label = "수식", Editor = PropEditorKind.MultilineText },
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "Button", DisplayName = "버튼", DefaultW = 90, DefaultH = 26,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
|
||||
new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag },
|
||||
new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text },
|
||||
new() { Key = "GetDataActionTagControl", Label = "입력 파라미터 컨트롤", Editor = PropEditorKind.Text },
|
||||
new() { Key = "KeyboardShortcut", Label = "단축키", Editor = PropEditorKind.Text },
|
||||
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "DataTable", DisplayName = "데이터소스", DefaultW = 34, DefaultH = 34,
|
||||
Properties = new PropertyDef[]
|
||||
{
|
||||
new() { Key = "Query", Label = "쿼리(SQL)", Editor = PropEditorKind.SqlQuery },
|
||||
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
|
||||
new() { Key = "ExcuteQuery", Label = "쿼리 실행", Editor = PropEditorKind.Toggle },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, ControlDescriptor> byType =
|
||||
all.ToDictionary(d => d.Type, StringComparer.Ordinal);
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>팔레트에 노출할 전체 컨트롤 목록</summary>
|
||||
public static IReadOnlyList<ControlDescriptor> All => all;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>타입명으로 기술자 조회 — 미지원 타입(Placeholder 포함)은 null</summary>
|
||||
public static ControlDescriptor? Find(string type)
|
||||
=> byType.TryGetValue(type, out var descriptor) ? descriptor : null;
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>팔레트 컨트롤 1종의 기술자</summary>
|
||||
public sealed class ControlDescriptor
|
||||
{
|
||||
/// <summary>중립 타입명(LegacyTypeCatalog 와 공유하는 키)</summary>
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>팔레트 표시명(한글)</summary>
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>드롭 시 기본 너비(px)</summary>
|
||||
public double DefaultW { get; init; }
|
||||
|
||||
/// <summary>드롭 시 기본 높이(px)</summary>
|
||||
public double DefaultH { get; init; }
|
||||
|
||||
/// <summary>컨테이너 여부(Panel/GroupBox — 자식 배치 가능)</summary>
|
||||
public bool IsContainer { get; init; }
|
||||
|
||||
/// <summary>배치 시 데이터 필드를 만드는 입력 컨트롤인지</summary>
|
||||
public bool CreatesField { get; init; }
|
||||
|
||||
/// <summary>필드 기본 데이터 타입</summary>
|
||||
public string DefaultDataType { get; init; } = "string";
|
||||
|
||||
/// <summary>인스펙터 속성 편집 스키마(타입 전용 — 공통 X/Y/W/H/Id/폰트는 인스펙터가 별도 제공)</summary>
|
||||
public IReadOnlyList<PropertyDef> Properties { get; init; } = Array.Empty<PropertyDef>();
|
||||
}
|
||||
|
||||
/// <summary>속성 편집기 종류</summary>
|
||||
public enum PropEditorKind
|
||||
{
|
||||
/// <summary>문자열</summary>
|
||||
Text,
|
||||
/// <summary>여러 줄 문자열</summary>
|
||||
MultilineText,
|
||||
/// <summary>숫자</summary>
|
||||
Number,
|
||||
/// <summary>참/거짓</summary>
|
||||
Toggle,
|
||||
/// <summary>선택지</summary>
|
||||
Choice,
|
||||
/// <summary>색(레거시 invariant 문자열)</summary>
|
||||
Color,
|
||||
/// <summary>문자열 목록(콤보 items 등 — 줄바꿈 구분 편집)</summary>
|
||||
StringList,
|
||||
/// <summary>데이터 태그 피커(bzDataInterface 메서드 — 자동 채움 원천)</summary>
|
||||
DataInterfaceTag,
|
||||
/// <summary>액션 태그 피커(EN_DataActionTyp — 더블클릭/버튼 액션)</summary>
|
||||
DataActionTag,
|
||||
/// <summary>SQL 쿼리 — 전용 편집기 창(치환 변수 삽입 지원)</summary>
|
||||
SqlQuery,
|
||||
}
|
||||
|
||||
/// <summary>인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집</summary>
|
||||
public sealed class PropertyDef
|
||||
{
|
||||
/// <summary>PropBag 키(레거시 Property 이름)</summary>
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>표시 라벨(한글)</summary>
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>편집기 종류</summary>
|
||||
public PropEditorKind Editor { get; init; }
|
||||
|
||||
/// <summary>Choice 편집기의 선택지</summary>
|
||||
public string[]? Choices { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 태그 값 카탈로그 — 인스펙터 피커의 선택지.
|
||||
/// 원본: [003]EMR\[002]UserControl\clsBaseUserControl.vb 의 EN_DataActionTyp(선언 순서 보존 — 값 의미상 순서 변경 금지),
|
||||
/// [001]Common\[012]DataTag\M.CMM.DataTag\bzDataInterface.vb 의 Public Function 목록 (2026-07-16 추출).
|
||||
/// 사이트 커스텀 태그는 텍스트 직접 입력으로 지원(피커는 선택 보조).
|
||||
/// </summary>
|
||||
public static class LegacyTagCatalog
|
||||
{
|
||||
/// <summary>액션 태그(EN_DataActionTyp) — 더블클릭/버튼 액션 종류</summary>
|
||||
public static readonly string[] DataActionTags =
|
||||
{
|
||||
"None",
|
||||
"DtrLicList",
|
||||
"OkdInfList",
|
||||
"ComPidList",
|
||||
"YondoList",
|
||||
"ImgeList",
|
||||
"ChoiceData",
|
||||
"AllCheck",
|
||||
"ReturnUidNam",
|
||||
"OprInfList",
|
||||
"TPR",
|
||||
"VisibleYon",
|
||||
"DateVisibleYon",
|
||||
"ReturnEmrData",
|
||||
"ReturnAllEmrData",
|
||||
"EmgMnsList",
|
||||
"EmgLevTim",
|
||||
"CodeFInd",
|
||||
"EmgKTAS",
|
||||
"NdsMst",
|
||||
"UmlsMst",
|
||||
"ComTrsDtm",
|
||||
"PikPs1",
|
||||
"ButtonData",
|
||||
"OkdInfData",
|
||||
"EmgTotalEmr",
|
||||
"LabOdrList",
|
||||
"PdeOdrList",
|
||||
"ComOkdList",
|
||||
"MakeDataList",
|
||||
"CodList",
|
||||
"PrgRcdList",
|
||||
"PanelVisible",
|
||||
"PanelUnVisible",
|
||||
"MinusToPlus",
|
||||
"TPRList",
|
||||
"OrderList",
|
||||
"BldOutList",
|
||||
"BlmInfList",
|
||||
"ReturnLabData",
|
||||
"ReturnLevOrderList",
|
||||
"SetVisibleStatus",
|
||||
"HiraSearch",
|
||||
"DataCopy_Btn",
|
||||
"DataCopy_MtM",
|
||||
"EndoOdrList",
|
||||
"EndoOdrList2",
|
||||
"LabOdrCodList",
|
||||
"상병발생진단일",
|
||||
"UnAllCheck",
|
||||
"MaterialList",
|
||||
"IcpMstList",
|
||||
"UserLicList",
|
||||
"SgaSearch",
|
||||
"OkdInfEngData",
|
||||
"OrderEngList",
|
||||
"DataCopy_Chk",
|
||||
"DataCopy_Chk_UidNam",
|
||||
"DataCopy_Opt",
|
||||
"PanelVisibility_Opt",
|
||||
"ComAcpLev",
|
||||
"ComAcpLev_O",
|
||||
"ComAcpLev_O_Ver2",
|
||||
"ComAcpLev_I",
|
||||
"ComAcpLev_I_Ver2",
|
||||
"ComAcpLev_I_Ver3",
|
||||
"CowInfData",
|
||||
"AllCheck_Opt",
|
||||
"EmgOkdInfData",
|
||||
"ChildIBWCalc",
|
||||
"EmgDetList",
|
||||
"OrderNameList",
|
||||
"NurseList",
|
||||
"EmrDataCopy",
|
||||
"TextYorN",
|
||||
"TextPlusMinus",
|
||||
"SMSWebLink",
|
||||
"RadCheckControl",
|
||||
"NonOdrCodList",
|
||||
"ZipFinder",
|
||||
"OprInfList_Cht",
|
||||
"ChkPatData",
|
||||
"PatInfList",
|
||||
"HiPassPatList",
|
||||
"ChildIBWValue",
|
||||
"DataTableRefresh",
|
||||
"PathologyInfo",
|
||||
"UserListTile_Doctor",
|
||||
"UserListTile_Nurse_OPR",
|
||||
"UserListTile_Nurse_ANE",
|
||||
"UserListTile_Nurse_ASS",
|
||||
"UserListTile_Nurse_CIR",
|
||||
"UserListTile_nurse_SCR",
|
||||
"UserListTile_Nurse_WRD",
|
||||
"UserListTile_LAB",
|
||||
"ReturnProgressRecord",
|
||||
"RstInfList",
|
||||
"TextConnect",
|
||||
"DssOkdOpnLst",
|
||||
"OprInfList_Multi",
|
||||
"IndAccDetail",
|
||||
"IndAccDetail_Chart",
|
||||
"ChkDataCopy",
|
||||
"ChkDataCopy_MtM",
|
||||
"AneDtrLicList",
|
||||
"PreOprInfList",
|
||||
"PresentTime",
|
||||
"SingleOprInfo",
|
||||
"GetTerminology",
|
||||
"ComTrsLev",
|
||||
"Expanded",
|
||||
"UnExpanded",
|
||||
"EyeTestResultView",
|
||||
"OkdInf_MainSub",
|
||||
"UserAllLicList",
|
||||
"DateTime_Calculation",
|
||||
"EmrSelectData",
|
||||
"DateTime_DateDiff_Day",
|
||||
"ComPidListDate",
|
||||
"AllCheckUnCheck",
|
||||
"NonOdrCodList_WithDtl",
|
||||
"EmrSelectData_Program",
|
||||
"PdeOdrList_OdrNam",
|
||||
"TPRList_VS_HW",
|
||||
"ProgressRecord",
|
||||
"XrayOdrCodList",
|
||||
"OprOrderList",
|
||||
"MakeNurseRecord_S077",
|
||||
"OprOkdList",
|
||||
"GCSCheck",
|
||||
"ComboBox_COPY_ATOB",
|
||||
"NonOdrCodList_Dep",
|
||||
"EvalutaionList",
|
||||
"DepMstList",
|
||||
"NurUidList",
|
||||
"PhaLicList",
|
||||
"OnsetInsert",
|
||||
"BldInfoDetail",
|
||||
"GetEmrData",
|
||||
"RefreshDataInterfaceTag",
|
||||
"ICUInfList",
|
||||
"OprUidList",
|
||||
"QueryResultViewer",
|
||||
"ComAcpLev_O_Ver3",
|
||||
"OpDtrLicList",
|
||||
"OrderCPTList",
|
||||
"GroupResultCheck",
|
||||
"OrderList_Usg",
|
||||
"UnAllCheck_Opt",
|
||||
};
|
||||
|
||||
/// <summary>데이터 태그(bzDataInterface 메서드) — 자동 채움 데이터 원천</summary>
|
||||
public static readonly string[] DataInterfaceTags =
|
||||
{
|
||||
"ECT_로그인사용자싸인",
|
||||
"ETC_건강보험증번호",
|
||||
"ETC_건강보험증번호_Refer",
|
||||
"ETC_로그인_근무부서",
|
||||
"ETC_로그인_근무부서_사용자명",
|
||||
"ETC_로그인_사용자ID",
|
||||
"ETC_로그인_사용자ID_사용자명",
|
||||
"ETC_로그인_사용자명",
|
||||
"ETC_로그인_사용자연락처",
|
||||
"ETC_로그인_원내연락처",
|
||||
"ETC_로그인_의사면허번호",
|
||||
"ETC_로그인_전문의번호",
|
||||
"ETC_로그인_전문의번호_Refer",
|
||||
"ETC_로그인_직급",
|
||||
"ETC_로그인사용자_병동_수간호사",
|
||||
"ETC_로그인사용자_병동_수간호사_싸인",
|
||||
"ETC_로그인사용자_병동간호사목록_InitalDatatable",
|
||||
"ETC_로그인사용자_서명",
|
||||
"ETC_마취간호사List_InitialDatatable",
|
||||
"ETC_마취과의사List_InitialDatatable",
|
||||
"ETC_마취및소독간호사List_InitialDatatable",
|
||||
"ETC_병리판독의사명",
|
||||
"ETC_병리판독의사싸인",
|
||||
"ETC_병리판독의사전문의번호",
|
||||
"ETC_산재지정번호",
|
||||
"ETC_소독간호사List_InitialDatatable",
|
||||
"ETC_수술_내시경간호사목록_InitalDatatable",
|
||||
"ETC_수술간호사목록",
|
||||
"ETC_수술간호사목록_InitalDatatable",
|
||||
"ETC_수술실간호사",
|
||||
"ETC_수술일자_1_몇년",
|
||||
"ETC_수술일자_1_몇년2자리",
|
||||
"ETC_수술일자_2_몇월",
|
||||
"ETC_수술일자_3_몇일",
|
||||
"ETC_순환간호사List_InitialDatatable",
|
||||
"ETC_승인의사",
|
||||
"ETC_어시스트간호사List_InitialDatatable",
|
||||
"ETC_영상의학과의사싸인",
|
||||
"ETC_외출외박_확인간호사싸인",
|
||||
"ETC_외출외박_확인원무과싸인",
|
||||
"ETC_요양기관기호",
|
||||
"ETC_요양기관명칭_병원대표자",
|
||||
"ETC_요양기관명칭_병원로고",
|
||||
"ETC_요양기관명칭_병원명",
|
||||
"ETC_요양기관명칭_병원명_영문",
|
||||
"ETC_요양기관명칭_병원장",
|
||||
"ETC_요양기관명칭_병원장귀하",
|
||||
"ETC_요양기관명칭_병원전화_팩스번호",
|
||||
"ETC_요양기관명칭_병원전화번호",
|
||||
"ETC_요양기관명칭_병원주소",
|
||||
"ETC_요양기관명칭_병원주소_영문",
|
||||
"ETC_요양기관명칭_병원직인",
|
||||
"ETC_요양기관명칭_병원팩스번호",
|
||||
"ETC_요양기관명칭_사업자등록번호",
|
||||
"ETC_요양기관명칭_영문_병원전화_팩스번호",
|
||||
"ETC_의사List_이름순",
|
||||
"ETC_재해발생일",
|
||||
"ETC_전체진료과_영문명",
|
||||
"ETC_전체진료과_한글명",
|
||||
"ETC_접속유저_부서번호",
|
||||
"ETC_제한향균제_InitialDatatable",
|
||||
"ETC_진단검사의사명",
|
||||
"ETC_진단검사의사싸인",
|
||||
"ETC_진단검사의사전문의번호",
|
||||
"ETC_진료과별의사List_이름순",
|
||||
"ETC_현재년도",
|
||||
"ETC_현재시간",
|
||||
"ETC_현재일시",
|
||||
"ETC_현재일시_1_년",
|
||||
"ETC_현재일시_2_월",
|
||||
"ETC_현재일시_3_일",
|
||||
"ETC_현재일시_4_몇시",
|
||||
"ETC_현재일시_5_몇분",
|
||||
"ETC_현재일시_MaskBox",
|
||||
"ETC_현재일자",
|
||||
"ETC_현재일자_영문",
|
||||
"GetNurseEmrDT",
|
||||
"GetNurseShtCod",
|
||||
"GetNurseShtCod_GCRCH",
|
||||
"GetNurseShtCod_GNBEDRO",
|
||||
"GetNurseShtCod_SRH",
|
||||
"GetOprInfDT",
|
||||
"GetOprInfDT_OKDMain",
|
||||
"OCM_BMI",
|
||||
"OCM_BST",
|
||||
"OCM_BST_LAST",
|
||||
"OCM_Cosign_싸인",
|
||||
"OCM_Cosign_의사명",
|
||||
"OCM_DX",
|
||||
"OCM_FollowUp",
|
||||
"OCM_LMP",
|
||||
"OCM_OPNAME",
|
||||
"OCM_OPRCODNAM",
|
||||
"OCM_SPO2",
|
||||
"OCM_SPO2_LAST",
|
||||
"OCM_STFDTR",
|
||||
"OCM_User진료과",
|
||||
"OCM_User진료과_Refer",
|
||||
"OCM_User진료과_한글명칭",
|
||||
"OCM_User진료의사명",
|
||||
"OCM_User진료의사명_Refer",
|
||||
"OCM_VITAL접수일시_LAST",
|
||||
"OCM_간호정보조사지_과거력",
|
||||
"OCM_감염분류_감염정보",
|
||||
"OCM_감염정보",
|
||||
"OCM_계산유형",
|
||||
"OCM_내원유형",
|
||||
"OCM_담당의사",
|
||||
"OCM_담당의사_Refer",
|
||||
"OCM_담당의사_담당의사코드",
|
||||
"OCM_담당의사_연락처",
|
||||
"OCM_담당의사_영문",
|
||||
"OCM_담당의사_영문_Refer",
|
||||
"OCM_담당의사면허번호",
|
||||
"OCM_담당의사면허번호_Refer",
|
||||
"OCM_담당의사싸인",
|
||||
"OCM_담당의사싸인_Refer",
|
||||
"OCM_담당의사전문의번호",
|
||||
"OCM_담당의사전문의번호_Refer",
|
||||
"OCM_당뇨식칼로리_List",
|
||||
"OCM_당일식이",
|
||||
"OCM_로그인_사용자싸인",
|
||||
"OCM_맥박",
|
||||
"OCM_맥박_LAST",
|
||||
"OCM_머리둘레",
|
||||
"OCM_머리둘레_LAST",
|
||||
"OCM_몸무게",
|
||||
"OCM_몸무게_LAST",
|
||||
"OCM_발병일",
|
||||
"OCM_병동",
|
||||
"OCM_병동_Refer",
|
||||
"OCM_병동_병실",
|
||||
"OCM_병동_병실_베드",
|
||||
"OCM_병실",
|
||||
"OCM_병실_Refer",
|
||||
"OCM_병실베드",
|
||||
"OCM_병실인실",
|
||||
"OCM_병실차액",
|
||||
"OCM_보험유형",
|
||||
"OCM_보험유형_Refer",
|
||||
"OCM_부상병POA",
|
||||
"OCM_부상병POA_Refer",
|
||||
"OCM_부상병명",
|
||||
"OCM_부상병명_Refer",
|
||||
"OCM_부상병코드",
|
||||
"OCM_부상병코드_Refer",
|
||||
"OCM_비급여처방",
|
||||
"OCM_비만도",
|
||||
"OCM_상병명_가로",
|
||||
"OCM_상병명_가로_Refer",
|
||||
"OCM_상병명_상병코드",
|
||||
"OCM_상병명_상병코드_Refer",
|
||||
"OCM_상병명_세로",
|
||||
"OCM_상병명_세로_Refer",
|
||||
"OCM_상병명_영어_가로",
|
||||
"OCM_상병명_영어_가로_Refer",
|
||||
"OCM_상병명_영어_세로",
|
||||
"OCM_상병명_영어_세로_Refer",
|
||||
"OCM_상병코드_가로",
|
||||
"OCM_상병코드_가로_Refer",
|
||||
"OCM_상병코드_상병명",
|
||||
"OCM_상병코드_상병명_Refer",
|
||||
"OCM_상병코드_상병명_부상병",
|
||||
"OCM_상병코드_상병명_부상병_Refer",
|
||||
"OCM_상병코드_상병명_주상병",
|
||||
"OCM_상병코드_상병명_주상병_Refer",
|
||||
"OCM_상병코드_세로",
|
||||
"OCM_상병코드_세로_Refer",
|
||||
"OCM_수납일시",
|
||||
"OCM_수납일시_Refer",
|
||||
"OCM_수술_OprPatETC",
|
||||
"OCM_수술마취의사",
|
||||
"OCM_수술마취의사싸인",
|
||||
"OCM_수술마취의사싸인_수술접수연동",
|
||||
"OCM_수술명칭",
|
||||
"OCM_수술부위",
|
||||
"OCM_수술상병",
|
||||
"OCM_수술소독간호사",
|
||||
"OCM_수술일자",
|
||||
"OCM_수술일자_Refer",
|
||||
"OCM_수술일자_마지막수술",
|
||||
"OCM_수술주상병",
|
||||
"OCM_수술진단명",
|
||||
"OCM_수술집도의",
|
||||
"OCM_수술집도의_그룹진료과",
|
||||
"OCM_수술집도의_싸인",
|
||||
"OCM_수술집도의_진료과",
|
||||
"OCM_수술처치",
|
||||
"OCM_신환_초진일",
|
||||
"OCM_신환_초진일_Refer",
|
||||
"OCM_심사_부상병명",
|
||||
"OCM_심사_부상병코드",
|
||||
"OCM_심사_주상병명",
|
||||
"OCM_심사_주상병코드",
|
||||
"OCM_알러지",
|
||||
"OCM_알러지_List",
|
||||
"OCM_알러지_기타",
|
||||
"OCM_알러지_약물",
|
||||
"OCM_알러지_음식",
|
||||
"OCM_알러지_조영제",
|
||||
"OCM_영상촬영이력",
|
||||
"OCM_영상촬영이력_Refer",
|
||||
"OCM_외래내원일시",
|
||||
"OCM_외래내원일시_Refer",
|
||||
"OCM_외래내원일자",
|
||||
"OCM_외래내원일자_Refer",
|
||||
"OCM_외래내원일자_영문",
|
||||
"OCM_외래실진료일",
|
||||
"OCM_외래실진료일_refer",
|
||||
"OCM_외래실진료일_과목별",
|
||||
"OCM_외래실진료일수",
|
||||
"OCM_외래실진료일수_Refer",
|
||||
"OCM_외출예상시작일시",
|
||||
"OCM_외출예상종료일시",
|
||||
"OCM_외출외박신청일",
|
||||
"OCM_외출외박종료일",
|
||||
"OCM_응급증상여부_NO",
|
||||
"OCM_응급증상여부_YES",
|
||||
"OCM_의사퇴원예고일시",
|
||||
"OCM_임신주기",
|
||||
"OCM_입실시간",
|
||||
"OCM_입원과",
|
||||
"OCM_입원과_Refer",
|
||||
"OCM_입원과_한글명칭",
|
||||
"OCM_입원내원일시",
|
||||
"OCM_입원내원일시_Refer",
|
||||
"OCM_입원내원일자",
|
||||
"OCM_입원내원일자_Refer",
|
||||
"OCM_입원시간",
|
||||
"OCM_입원시간_Refer",
|
||||
"OCM_입원의사",
|
||||
"OCM_입원의사_Refer",
|
||||
"OCM_입원일시",
|
||||
"OCM_입원일시_Refer",
|
||||
"OCM_입원일시_영문",
|
||||
"OCM_입원일자_낮병동",
|
||||
"OCM_입원전환일시",
|
||||
"OCM_입원전환일시_Refer",
|
||||
"OCM_입원전환일자",
|
||||
"OCM_입원전환일자_Refer",
|
||||
"OCM_입통원구분_입원",
|
||||
"OCM_입통원구분_입원_Refer",
|
||||
"OCM_입통원구분_통원",
|
||||
"OCM_입통원구분_통원_Refer",
|
||||
"OCM_재원일수",
|
||||
"OCM_재원일수_Refer",
|
||||
"OCM_재원일수_낮병동",
|
||||
"OCM_재원일수_단입법",
|
||||
"OCM_적용진료과",
|
||||
"OCM_적용진료의사",
|
||||
"OCM_전과",
|
||||
"OCM_전과내역",
|
||||
"OCM_전과내역_Refer",
|
||||
"OCM_전과일시",
|
||||
"OCM_전과일자",
|
||||
"OCM_조정체중_LAST",
|
||||
"OCM_주상병POA",
|
||||
"OCM_주상병POA_Refer",
|
||||
"OCM_주상병명",
|
||||
"OCM_주상병명_Refer",
|
||||
"OCM_주상병명_영어",
|
||||
"OCM_주상병명_영어_Refer",
|
||||
"OCM_주상병일자",
|
||||
"OCM_주상병일자_Refer",
|
||||
"OCM_주상병코드",
|
||||
"OCM_주상병코드_Refer",
|
||||
"OCM_주상병특정기호",
|
||||
"OCM_주상병특정기호_Refer",
|
||||
"OCM_진단구분_상병명",
|
||||
"OCM_진단구분_상병코드_상병명",
|
||||
"OCM_진단구분_상병코드_상병명_Refer",
|
||||
"OCM_진단서의사_영문",
|
||||
"OCM_진단서의사_영문_Refer",
|
||||
"OCM_진단서의사면허번호",
|
||||
"OCM_진단서의사면허번호_Refer",
|
||||
"OCM_진단서의사명",
|
||||
"OCM_진단서의사명_Refer",
|
||||
"OCM_진단서의사명_외래",
|
||||
"OCM_진단서의사세부전문의번호",
|
||||
"OCM_진단서의사싸인",
|
||||
"OCM_진단서의사싸인_Refer",
|
||||
"OCM_진단서의사전문의번호",
|
||||
"OCM_진단서의사전문의번호_Refer",
|
||||
"OCM_진단서전문과목",
|
||||
"OCM_진단서전문과목_Refer",
|
||||
"OCM_진단서전문그룹과목",
|
||||
"OCM_진단서전문그룹과목_Refer",
|
||||
"OCM_진단서청구진료과목_로그인",
|
||||
"OCM_진료과",
|
||||
"OCM_진료과_Refer",
|
||||
"OCM_진료과_그룹코드",
|
||||
"OCM_진료과_영어",
|
||||
"OCM_진료과_전화번호",
|
||||
"OCM_진료과대외명칭",
|
||||
"OCM_진료과대외명칭_Refer",
|
||||
"OCM_진료과약어명칭",
|
||||
"OCM_진료기간",
|
||||
"OCM_진료기간_Refer",
|
||||
"OCM_진료기간_진료과",
|
||||
"OCM_진료기간_진료과_Refer",
|
||||
"OCM_청구진료과목",
|
||||
"OCM_청구진료과목_Refer",
|
||||
"OCM_체온",
|
||||
"OCM_체온_LAST",
|
||||
"OCM_초진일_발병일",
|
||||
"OCM_초진일_발병일_Refer",
|
||||
"OCM_최초내원일",
|
||||
"OCM_키",
|
||||
"OCM_키_LAST",
|
||||
"OCM_퇴원과",
|
||||
"OCM_퇴원과_Refer",
|
||||
"OCM_퇴원과_한글명칭",
|
||||
"OCM_퇴원분석_수술처치명",
|
||||
"OCM_퇴원시간",
|
||||
"OCM_퇴원시간_Refer",
|
||||
"OCM_퇴원약",
|
||||
"OCM_퇴원약_New",
|
||||
"OCM_퇴원약_Refer",
|
||||
"OCM_퇴원약_명칭",
|
||||
"OCM_퇴원약_용량",
|
||||
"OCM_퇴원약_용법",
|
||||
"OCM_퇴원약_일수",
|
||||
"OCM_퇴원약_코드",
|
||||
"OCM_퇴원약_횟수",
|
||||
"OCM_퇴원예고일시",
|
||||
"OCM_퇴원예고재원일수",
|
||||
"OCM_퇴원예정일시",
|
||||
"OCM_퇴원의사",
|
||||
"OCM_퇴원의사_Refer",
|
||||
"OCM_퇴원일시",
|
||||
"OCM_퇴원일시_Refer",
|
||||
"OCM_퇴원일시_영문",
|
||||
"OCM_퇴원일자_낮병동",
|
||||
"OCM_표준체중_LAST",
|
||||
"OCM_혈압",
|
||||
"OCM_혈압_BPD",
|
||||
"OCM_혈압_BPS",
|
||||
"OCM_혈압_LAST",
|
||||
"OCM_혈액형",
|
||||
"OCM_협진과",
|
||||
"OCM_협진과_협진의",
|
||||
"OCM_호흡",
|
||||
"OCM_호흡_LAST",
|
||||
"PAT_BMI",
|
||||
"PAT_IBW",
|
||||
"PAT_IBW_퍼센트",
|
||||
"PAT_건보_급여_세대주명",
|
||||
"PAT_건보세대주명",
|
||||
"PAT_국적",
|
||||
"PAT_나이",
|
||||
"PAT_나이_00Months_00Days",
|
||||
"PAT_나이_00개월_00일",
|
||||
"PAT_나이_00개월_일",
|
||||
"PAT_나이_개월_00일",
|
||||
"PAT_나이_개월수",
|
||||
"PAT_나이_세",
|
||||
"PAT_도로명주소",
|
||||
"PAT_보호자연락처",
|
||||
"PAT_생년월일",
|
||||
"PAT_생년월일_영문",
|
||||
"PAT_생년월일_영문_일월년도",
|
||||
"PAT_성별",
|
||||
"PAT_성별_나이",
|
||||
"PAT_성별_남",
|
||||
"PAT_성별_여",
|
||||
"PAT_실제생년월일",
|
||||
"PAT_여권번호",
|
||||
"PAT_연락처",
|
||||
"PAT_연락처_뒷4자리",
|
||||
"PAT_영문도로명주소",
|
||||
"PAT_영문이름",
|
||||
"PAT_우편번호",
|
||||
"PAT_이름",
|
||||
"PAT_자택전화번호",
|
||||
"PAT_장애등급",
|
||||
"PAT_주민번호",
|
||||
"PAT_주민번호_Blind",
|
||||
"PAT_주민번호_Dash",
|
||||
"PAT_주민번호_SexTyp_Blind",
|
||||
"PAT_주민번호_SexTyp_BlindStar",
|
||||
"PAT_지번주소",
|
||||
"PAT_차트번호",
|
||||
"PAT_한글성별",
|
||||
"PAT_협력업체",
|
||||
"PAT_휴대전화번호",
|
||||
"PAT_휴대전화번호_뒷4자리",
|
||||
"퇴원약DT",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 배치된 컨트롤 1개 — 레거시 XML의 <Object> 노드 1개에 대응.
|
||||
/// 모든 Property 는 <see cref="Props"/>에 원본 순서로 무손실 보존되고,
|
||||
/// 편집 편의를 위해 Location/Size 만 <see cref="Bounds"/>로 리프트한다
|
||||
/// (편집 중 Props 의 Location/Size 는 stale — 쓰기 시 Bounds 값으로 동기화된다).
|
||||
/// </summary>
|
||||
public sealed class ControlElement
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>중립 타입명 — ControlRegistry/LegacyTypeCatalog 의 키. 미지원 타입은 "Placeholder"</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>컨트롤 이름(name 어트리뷰트) — 레거시 SctObjNam 등가, 문서 내 유일해야 함</summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>원본 AssemblyQualifiedName — 읽기 시 원문 보존, 신규 생성 시 카탈로그에서 채움</summary>
|
||||
public string? LegacyAqn { get; set; }
|
||||
|
||||
/// <summary>원본에 name 어트리뷰트가 있었는지 (왕복 보존)</summary>
|
||||
public bool HasNameAttr { get; set; } = true;
|
||||
|
||||
/// <summary>원본에 children 어트리뷰트가 있었는지 (Control 이면 "Controls")</summary>
|
||||
public bool HasChildrenAttr { get; set; } = true;
|
||||
|
||||
/// <summary>Object 어트리뷰트 DisplaySequence 값 (레거시 writer 는 항상 0 기록)</summary>
|
||||
public int DisplaySequence { get; set; }
|
||||
|
||||
/// <summary>부모 기준 배치(px) — Props 의 Location/Size 리프트</summary>
|
||||
public LayoutRect Bounds { get; set; } = new();
|
||||
|
||||
/// <summary>원본 Props 에 Location 이 있었는지 — 쓰기 시 생략/기록 판단</summary>
|
||||
public bool HasLocation { get; set; }
|
||||
|
||||
/// <summary>원본 Props 에 Size 가 있었는지 — 쓰기 시 생략/기록 판단</summary>
|
||||
public bool HasSize { get; set; }
|
||||
|
||||
/// <summary>전체 속성 가방(원본 순서 보존) — Location/Size 포함(쓰기 시 Bounds 로 동기화)</summary>
|
||||
public PropBag Props { get; set; } = new();
|
||||
|
||||
/// <summary>자식 컨트롤 — 레거시 Controls 컬렉션 순서 그대로(index 0 = 최상위 z-order)</summary>
|
||||
public List<ControlElement> Children { get; set; } = new();
|
||||
|
||||
/// <summary>디자이너 편집 잠금(직렬화 안 함 — 편집 전용 상태)</summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public bool Locked { get; set; }
|
||||
|
||||
/// <summary>디자이너 임시 숨김(직렬화 안 함 — 편집 전용 상태, 레거시 Visible 과 별개)</summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public bool Hidden { get; set; }
|
||||
|
||||
/// <summary>디자이너 그룹 식별자(직렬화 안 함 — 편집 전용 상태)</summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public string? GroupId { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>텍스트 속성(Text) 편의 접근 — 없으면 빈 문자열</summary>
|
||||
public string GetText() => Props.GetText("Text") ?? string.Empty;
|
||||
|
||||
/// <summary>깊은 복제(자식·속성 포함)</summary>
|
||||
public ControlElement Clone()
|
||||
{
|
||||
var clone = new ControlElement
|
||||
{
|
||||
Type = Type,
|
||||
Id = Id,
|
||||
LegacyAqn = LegacyAqn,
|
||||
HasNameAttr = HasNameAttr,
|
||||
HasChildrenAttr = HasChildrenAttr,
|
||||
DisplaySequence = DisplaySequence,
|
||||
Bounds = Bounds.Clone(),
|
||||
HasLocation = HasLocation,
|
||||
HasSize = HasSize,
|
||||
Props = Props.Clone(),
|
||||
Locked = Locked,
|
||||
Hidden = Hidden,
|
||||
GroupId = GroupId,
|
||||
};
|
||||
foreach (var child in Children)
|
||||
{
|
||||
clone.Children.Add(child.Clone());
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 서식 문서 — 직렬화(레거시 XML/JSON)의 단위. 페이지 목록과 메타데이터를 갖는다.
|
||||
/// FormId/Title 은 XML 본문이 아닌 DB(E_ShtMst)에서 오는 메타데이터다.
|
||||
/// </summary>
|
||||
public sealed class FormDocument
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>서식 코드 — E_ShtMst.ShtCod (파일 기반일 때는 파일명 유래)</summary>
|
||||
public string FormId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>서식 명칭 — E_ShtMst.ShtKorNam</summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>페이지 목록(순서 = 페이지 순서)</summary>
|
||||
public List<FormPage> Pages { get; set; } = new();
|
||||
|
||||
/// <summary>부가 메타(출처/감사/읽기 경고)</summary>
|
||||
public FormMeta Meta { get; set; } = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>깊은 복제 — Undo 스냅샷용(편집 전용 상태 포함)</summary>
|
||||
public FormDocument Clone()
|
||||
{
|
||||
var clone = new FormDocument
|
||||
{
|
||||
FormId = FormId,
|
||||
Title = Title,
|
||||
Meta = Meta.Clone(),
|
||||
};
|
||||
foreach (var page in Pages)
|
||||
{
|
||||
clone.Pages.Add(page.Clone());
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>서식 문서 부가 메타 — 출처·감사 정보와 읽기 시 수집된 경고.</summary>
|
||||
public sealed class FormMeta
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>레거시 가져오기 출처 디자인 키 — E_SdgMst.SdgKey</summary>
|
||||
public decimal? SourceSdgKey { get; set; }
|
||||
|
||||
/// <summary>최종 수정자</summary>
|
||||
public string? UpdatedBy { get; set; }
|
||||
|
||||
/// <summary>최종 수정일시(yyyyMMddHHmmss)</summary>
|
||||
public string? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>읽기(파싱) 중 수집된 경고 — 직렬화하지 않음</summary>
|
||||
[JsonIgnore]
|
||||
public List<string> ReadWarnings { get; } = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>복제(경고 제외)</summary>
|
||||
public FormMeta Clone() => new()
|
||||
{
|
||||
SourceSdgKey = SourceSdgKey,
|
||||
UpdatedBy = UpdatedBy,
|
||||
UpdatedAt = UpdatedAt,
|
||||
};
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 서식 페이지 1장 — 레거시의 MDesignerHost 루트 <Object> 1개에 대응.
|
||||
/// 루트 노드 자체를 <see cref="Root"/>(ControlElement)로 보존해 루트 속성까지 무손실 왕복한다.
|
||||
/// </summary>
|
||||
public sealed class FormPage
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>페이지 루트(MDesignerHost) — 자식이 페이지의 컨트롤들</summary>
|
||||
public ControlElement Root { get; set; } = new();
|
||||
|
||||
/// <summary>페이지의 컨트롤 목록(루트의 자식) — index 0 = 최상위 z-order(레거시 규약)</summary>
|
||||
[JsonIgnore]
|
||||
public List<ControlElement> Controls => Root.Children;
|
||||
|
||||
/// <summary>페이지 너비(px) — 루트 Size 리프트, 레거시 기본 720</summary>
|
||||
[JsonIgnore]
|
||||
public double Width
|
||||
{
|
||||
get => Root.Bounds.W > 0 ? Root.Bounds.W : 720;
|
||||
set => Root.Bounds.W = value;
|
||||
}
|
||||
|
||||
/// <summary>페이지 높이(px) — 루트 Size 리프트, 레거시 기본 856</summary>
|
||||
[JsonIgnore]
|
||||
public double Height
|
||||
{
|
||||
get => Root.Bounds.H > 0 ? Root.Bounds.H : 856;
|
||||
set => Root.Bounds.H = value;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>깊은 복제</summary>
|
||||
public FormPage Clone() => new() { Root = Root.Clone() };
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 배치 사각형 — 부모(페이지 루트 또는 컨테이너) 기준 상대 좌표(px, WinForms px = WPF DIP 1:1).
|
||||
/// </summary>
|
||||
public sealed class LayoutRect
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>부모 기준 X(px)</summary>
|
||||
public double X { get; set; }
|
||||
|
||||
/// <summary>부모 기준 Y(px)</summary>
|
||||
public double Y { get; set; }
|
||||
|
||||
/// <summary>너비(px)</summary>
|
||||
public double W { get; set; }
|
||||
|
||||
/// <summary>높이(px)</summary>
|
||||
public double H { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>복제</summary>
|
||||
public LayoutRect Clone() => new() { X = X, Y = Y, W = W, H = H };
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 서식 XML의 Property 값 변형(variant) 기본형.
|
||||
/// 레거시 직렬화기(clsBasicDesignerLoader.WriteValue)가 만들어내는 5가지 형태 + 빈 값(Null)을
|
||||
/// 원형 그대로 보존해 무손실 왕복을 보장한다.
|
||||
/// </summary>
|
||||
public abstract class LegacyPropValue
|
||||
{
|
||||
/// <summary>텍스트 값 — <Property>text</Property> (TypeConverter invariant 문자열)</summary>
|
||||
public sealed class TextValue : LegacyPropValue
|
||||
{
|
||||
/// <summary>속성 문자열 값</summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
public TextValue() { }
|
||||
public TextValue(string value) => Value = value;
|
||||
|
||||
public override LegacyPropValue Clone() => new TextValue(Value);
|
||||
}
|
||||
|
||||
/// <summary>빈 값 — <Property /> 또는 내용 없는 요소 (로더에서 null 로 설정됨)</summary>
|
||||
public sealed class NullValue : LegacyPropValue
|
||||
{
|
||||
public override LegacyPropValue Clone() => new NullValue();
|
||||
}
|
||||
|
||||
/// <summary>중첩 속성 — Content 직렬화 속성의 자식 <Property> 목록 (예: DataBindings)</summary>
|
||||
public sealed class NestedValue : LegacyPropValue
|
||||
{
|
||||
/// <summary>자식 Property 가방</summary>
|
||||
public PropBag Children { get; set; } = new();
|
||||
|
||||
public override LegacyPropValue Clone() => new NestedValue { Children = Children.Clone() };
|
||||
}
|
||||
|
||||
/// <summary>컬렉션 항목 — Content IList 속성의 <Item type="AQN"> 목록 (예: ComboBox Items)</summary>
|
||||
public sealed class ItemsValue : LegacyPropValue
|
||||
{
|
||||
/// <summary>항목 목록</summary>
|
||||
public List<LegacyItem> Items { get; set; } = new();
|
||||
|
||||
public override LegacyPropValue Clone()
|
||||
{
|
||||
var clone = new ItemsValue();
|
||||
foreach (var item in Items)
|
||||
{
|
||||
clone.Items.Add(new LegacyItem { Aqn = item.Aqn, Value = item.Value.Clone() });
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>바이너리 값 — <Binary>base64</Binary> (BinaryFormatter/Byte[] 직렬화 잔재, verbatim 보존)</summary>
|
||||
public sealed class BinaryValue : LegacyPropValue
|
||||
{
|
||||
/// <summary>base64 원문</summary>
|
||||
public string Base64 { get; set; } = string.Empty;
|
||||
|
||||
public override LegacyPropValue Clone() => new BinaryValue { Base64 = Base64 };
|
||||
}
|
||||
|
||||
/// <summary>컴포넌트 참조 — <Reference name="..." /> (같은 호스트 내 사이트 참조)</summary>
|
||||
public sealed class ReferenceValue : LegacyPropValue
|
||||
{
|
||||
/// <summary>참조 대상 컴포넌트 이름</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public override LegacyPropValue Clone() => new ReferenceValue { Name = Name };
|
||||
}
|
||||
|
||||
/// <summary>깊은 복제</summary>
|
||||
public abstract LegacyPropValue Clone();
|
||||
}
|
||||
|
||||
/// <summary>Content IList 속성의 항목 1개 — <Item type="AQN">값</Item></summary>
|
||||
public sealed class LegacyItem
|
||||
{
|
||||
/// <summary>항목 타입의 AssemblyQualifiedName 원문</summary>
|
||||
public string Aqn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>항목 값(대개 TextValue)</summary>
|
||||
public LegacyPropValue Value { get; set; } = new LegacyPropValue.NullValue();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace SheetMe.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 순서 보존 속성 가방 — 레거시 XML의 <Property> 목록을 원본 순서 그대로 담는다.
|
||||
/// 값 갱신 시 원래 위치를 유지하고, 새 키는 뒤에 추가되어 왕복(diff) 안정성을 보장한다.
|
||||
/// </summary>
|
||||
public sealed class PropBag : IEnumerable<KeyValuePair<string, LegacyPropValue>>
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly List<string> keys = new();
|
||||
private readonly Dictionary<string, LegacyPropValue> map = new(StringComparer.Ordinal);
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>속성 개수</summary>
|
||||
public int Count => keys.Count;
|
||||
|
||||
/// <summary>속성 이름 목록(원본 순서)</summary>
|
||||
public IReadOnlyList<string> Keys => keys;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>속성 값 조회 — 없으면 null</summary>
|
||||
public LegacyPropValue? Get(string name)
|
||||
=> map.TryGetValue(name, out var value) ? value : null;
|
||||
|
||||
/// <summary>텍스트 속성 값 조회 — TextValue 가 아니면 null</summary>
|
||||
public string? GetText(string name)
|
||||
=> Get(name) is LegacyPropValue.TextValue text ? text.Value : null;
|
||||
|
||||
/// <summary>속성 존재 여부</summary>
|
||||
public bool Contains(string name) => map.ContainsKey(name);
|
||||
|
||||
/// <summary>속성 설정 — 기존 키는 위치 유지, 새 키는 뒤에 추가</summary>
|
||||
public void Set(string name, LegacyPropValue value)
|
||||
{
|
||||
if (!map.ContainsKey(name))
|
||||
{
|
||||
keys.Add(name);
|
||||
}
|
||||
map[name] = value;
|
||||
}
|
||||
|
||||
/// <summary>텍스트 속성 설정</summary>
|
||||
public void SetText(string name, string value)
|
||||
=> Set(name, new LegacyPropValue.TextValue(value));
|
||||
|
||||
/// <summary>속성 제거</summary>
|
||||
public bool Remove(string name)
|
||||
{
|
||||
if (!map.Remove(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
keys.Remove(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>지정 키 바로 앞에 새 키 삽입 — 기준 키가 없으면 뒤에 추가</summary>
|
||||
public void InsertBefore(string anchorName, string name, LegacyPropValue value)
|
||||
{
|
||||
if (map.ContainsKey(name))
|
||||
{
|
||||
map[name] = value;
|
||||
return;
|
||||
}
|
||||
var index = keys.IndexOf(anchorName);
|
||||
if (index < 0)
|
||||
{
|
||||
keys.Add(name);
|
||||
}
|
||||
else
|
||||
{
|
||||
keys.Insert(index, name);
|
||||
}
|
||||
map[name] = value;
|
||||
}
|
||||
|
||||
/// <summary>깊은 복제</summary>
|
||||
public PropBag Clone()
|
||||
{
|
||||
var clone = new PropBag();
|
||||
foreach (var key in keys)
|
||||
{
|
||||
clone.Set(key, map[key].Clone());
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
public IEnumerator<KeyValuePair<string, LegacyPropValue>> GetEnumerator()
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
yield return new KeyValuePair<string, LegacyPropValue>(key, map[key]);
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Xml;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>Spread 셀 1개(텍스트/스팬)</summary>
|
||||
public sealed class SpreadCellInfo
|
||||
{
|
||||
/// <summary>행 인덱스(0-base)</summary>
|
||||
public int Row { get; init; }
|
||||
|
||||
/// <summary>열 인덱스(0-base)</summary>
|
||||
public int Col { get; init; }
|
||||
|
||||
/// <summary>행 스팬(기본 1)</summary>
|
||||
public int RowSpan { get; init; } = 1;
|
||||
|
||||
/// <summary>열 스팬(기본 1)</summary>
|
||||
public int ColSpan { get; init; } = 1;
|
||||
|
||||
/// <summary>셀 텍스트</summary>
|
||||
public string Text { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Spread 격자 파싱 결과 — 렌더에 필요한 최소 정보</summary>
|
||||
public sealed class SpreadGridInfo
|
||||
{
|
||||
/// <summary>기본 행 높이(px) — FarPoint 기본 20</summary>
|
||||
public double DefaultRowHeight { get; set; } = 20;
|
||||
|
||||
/// <summary>기본 열 너비(px) — FarPoint 기본 62</summary>
|
||||
public double DefaultColWidth { get; set; } = 62;
|
||||
|
||||
/// <summary>행별 명시 높이(index → px)</summary>
|
||||
public Dictionary<int, double> RowHeights { get; } = new();
|
||||
|
||||
/// <summary>열별 명시 너비(index → px)</summary>
|
||||
public Dictionary<int, double> ColWidths { get; } = new();
|
||||
|
||||
/// <summary>텍스트 있는 셀 목록</summary>
|
||||
public List<SpreadCellInfo> Cells { get; } = new();
|
||||
|
||||
/// <summary>셀 스팬 목록(텍스트 없어도 병합 표시)</summary>
|
||||
public List<SpreadCellInfo> Spans { get; } = new();
|
||||
|
||||
/// <summary>행 높이 조회</summary>
|
||||
public double RowHeightOf(int row) => RowHeights.TryGetValue(row, out var h) ? h : DefaultRowHeight;
|
||||
|
||||
/// <summary>열 너비 조회</summary>
|
||||
public double ColWidthOf(int col) => ColWidths.TryGetValue(col, out var w) ? w : DefaultColWidth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FarPoint Spread 직렬화 XML(E_SpdMst.SpdDesign) 파서 — 렌더용 최소 추출.
|
||||
/// 근거: 실DB S999/P062 샘플(기본 골격) + FarPoint 표준 직렬화 구조.
|
||||
/// 셀 데이터(<Cell Row Column>)와 스팬(CellRange)은 실물 부재로 합성 샘플 검증 —
|
||||
/// 셀 있는 실서식 확보 시 재검증 필요(주석 유지).
|
||||
/// </summary>
|
||||
public static class FarPointSpreadParser
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>SpdDesign XML 파싱 — 실패 시 null(호출부가 자리표시 렌더 유지)</summary>
|
||||
public static SpreadGridInfo? Parse(string spdDesignXml)
|
||||
{
|
||||
try
|
||||
{
|
||||
var xml = LegacyXmlSerializer.LoadLenient(spdDesignXml);
|
||||
var info = new SpreadGridInfo();
|
||||
|
||||
// 축 크기 — Presentation//AxisModels 의 Row(세로 축=행 높이)/Column(가로 축=열 너비)
|
||||
foreach (XmlElement axis in xml.SelectNodes("//AxisModels/Row")!)
|
||||
{
|
||||
ParseAxis(axis, info.RowHeights, size => info.DefaultRowHeight = size);
|
||||
}
|
||||
foreach (XmlElement axis in xml.SelectNodes("//AxisModels/Column")!)
|
||||
{
|
||||
ParseAxis(axis, info.ColWidths, size => info.DefaultColWidth = size);
|
||||
}
|
||||
|
||||
// 셀 스팬 — SpanModel 하위 CellRange(Row/Column/RowCount/ColumnCount)
|
||||
foreach (XmlElement range in xml.SelectNodes("//SpanModel//CellRange")!)
|
||||
{
|
||||
info.Spans.Add(new SpreadCellInfo
|
||||
{
|
||||
Row = IntAttr(range, "Row"),
|
||||
Col = IntAttr(range, "Column"),
|
||||
RowSpan = Math.Max(1, IntAttr(range, "RowCount", 1)),
|
||||
ColSpan = Math.Max(1, IntAttr(range, "ColumnCount", 1)),
|
||||
});
|
||||
}
|
||||
|
||||
// 셀 텍스트 — Data 섹션 DataArea 하위 <Cell Row= Column=> 의 텍스트
|
||||
foreach (XmlElement cell in xml.SelectNodes("//Data//DataArea//Cell")!)
|
||||
{
|
||||
var text = cell.InnerText.Trim();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
info.Cells.Add(new SpreadCellInfo
|
||||
{
|
||||
Row = IntAttr(cell, "Row"),
|
||||
Col = IntAttr(cell, "Column"),
|
||||
Text = text,
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>축 Items 파싱 — Item(index, Size). index=-1 은 기본 크기 지정</summary>
|
||||
private static void ParseAxis(XmlElement axis, Dictionary<int, double> sizes, Action<double> setDefault)
|
||||
{
|
||||
var defaultAttr = axis.GetAttribute("defaultSize");
|
||||
if (double.TryParse(defaultAttr, out var defaultSize) && defaultSize > 0)
|
||||
{
|
||||
setDefault(defaultSize);
|
||||
}
|
||||
foreach (XmlElement item in axis.SelectNodes("Items/Item")!)
|
||||
{
|
||||
var index = IntAttr(item, "index", -1);
|
||||
var sizeNode = item.SelectSingleNode("Size");
|
||||
if (sizeNode is null || !double.TryParse(sizeNode.InnerText, out var size) || size <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (index < 0)
|
||||
{
|
||||
setDefault(size);
|
||||
}
|
||||
else
|
||||
{
|
||||
sizes[index] = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int IntAttr(XmlElement element, string name, int fallback = 0)
|
||||
=> int.TryParse(element.GetAttribute(name), out var value) ? value : fallback;
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// JSON 직렬화기 — 부 포맷(파일 내보내기/백업, 추후 주 포맷 승격 대비).
|
||||
/// 모델 트리를 있는 그대로 담아 레거시 XML 과 동일한 정보량을 유지한다.
|
||||
/// </summary>
|
||||
public sealed class FormJsonSerializer : IDesignSerializer
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly JsonSerializerOptions options = CreateOptions();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>JSON 을 서식 문서로 역직렬화</summary>
|
||||
public FormDocument Read(string content)
|
||||
=> JsonSerializer.Deserialize<FormDocument>(content, options)
|
||||
?? throw new InvalidDataException("JSON 서식 문서를 해석할 수 없습니다.");
|
||||
|
||||
/// <summary>서식 문서를 JSON 으로 직렬화</summary>
|
||||
public string Write(FormDocument document)
|
||||
=> JsonSerializer.Serialize(document, options);
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
opts.Converters.Add(new PropBagJsonConverter());
|
||||
opts.Converters.Add(new LegacyPropValueJsonConverter());
|
||||
return opts;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>PropBag JSON 컨버터 — 순서 보존을 위해 [{ "n": 이름, "v": 값 }] 배열로 직렬화</summary>
|
||||
public sealed class PropBagJsonConverter : JsonConverter<PropBag>
|
||||
{
|
||||
public override PropBag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var bag = new PropBag();
|
||||
if (reader.TokenType != JsonTokenType.StartArray)
|
||||
{
|
||||
throw new JsonException("PropBag 은 배열이어야 합니다.");
|
||||
}
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException("PropBag 항목은 객체여야 합니다.");
|
||||
}
|
||||
string? name = null;
|
||||
LegacyPropValue? value = null;
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
|
||||
{
|
||||
var propName = reader.GetString();
|
||||
reader.Read();
|
||||
switch (propName)
|
||||
{
|
||||
case "n":
|
||||
name = reader.GetString();
|
||||
break;
|
||||
case "v":
|
||||
value = JsonSerializer.Deserialize<LegacyPropValue>(ref reader, options);
|
||||
break;
|
||||
default:
|
||||
reader.Skip();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (name is not null)
|
||||
{
|
||||
bag.Set(name, value ?? new LegacyPropValue.NullValue());
|
||||
}
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, PropBag value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var (name, propValue) in value)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("n", name);
|
||||
writer.WritePropertyName("v");
|
||||
JsonSerializer.Serialize(writer, propValue, options);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>LegacyPropValue 다형 JSON 컨버터 — "k" 판별자 기반</summary>
|
||||
public sealed class LegacyPropValueJsonConverter : JsonConverter<LegacyPropValue>
|
||||
{
|
||||
public override LegacyPropValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return new LegacyPropValue.NullValue();
|
||||
}
|
||||
if (reader.TokenType == JsonTokenType.String)
|
||||
{
|
||||
// 축약형: 문자열이면 TextValue
|
||||
return new LegacyPropValue.TextValue(reader.GetString() ?? string.Empty);
|
||||
}
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException("LegacyPropValue 는 문자열 또는 객체여야 합니다.");
|
||||
}
|
||||
|
||||
string? kind = null;
|
||||
string? text = null;
|
||||
string? base64 = null;
|
||||
string? refName = null;
|
||||
PropBag? nested = null;
|
||||
List<LegacyItem>? items = null;
|
||||
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
|
||||
{
|
||||
var propName = reader.GetString();
|
||||
reader.Read();
|
||||
switch (propName)
|
||||
{
|
||||
case "k": kind = reader.GetString(); break;
|
||||
case "text": text = reader.GetString(); break;
|
||||
case "b64": base64 = reader.GetString(); break;
|
||||
case "ref": refName = reader.GetString(); break;
|
||||
case "props": nested = JsonSerializer.Deserialize<PropBag>(ref reader, options); break;
|
||||
case "items":
|
||||
items = new List<LegacyItem>();
|
||||
if (reader.TokenType != JsonTokenType.StartArray)
|
||||
{
|
||||
throw new JsonException("items 는 배열이어야 합니다.");
|
||||
}
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
|
||||
{
|
||||
string? aqn = null;
|
||||
LegacyPropValue? itemValue = null;
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
|
||||
{
|
||||
var itemProp = reader.GetString();
|
||||
reader.Read();
|
||||
switch (itemProp)
|
||||
{
|
||||
case "aqn": aqn = reader.GetString(); break;
|
||||
case "v": itemValue = JsonSerializer.Deserialize<LegacyPropValue>(ref reader, options); break;
|
||||
default: reader.Skip(); break;
|
||||
}
|
||||
}
|
||||
items.Add(new LegacyItem { Aqn = aqn ?? string.Empty, Value = itemValue ?? new LegacyPropValue.NullValue() });
|
||||
}
|
||||
break;
|
||||
default: reader.Skip(); break;
|
||||
}
|
||||
}
|
||||
|
||||
return kind switch
|
||||
{
|
||||
"text" => new LegacyPropValue.TextValue(text ?? string.Empty),
|
||||
"null" => new LegacyPropValue.NullValue(),
|
||||
"nested" => new LegacyPropValue.NestedValue { Children = nested ?? new PropBag() },
|
||||
"items" => new LegacyPropValue.ItemsValue { Items = items ?? new List<LegacyItem>() },
|
||||
"binary" => new LegacyPropValue.BinaryValue { Base64 = base64 ?? string.Empty },
|
||||
"ref" => new LegacyPropValue.ReferenceValue { Name = refName ?? string.Empty },
|
||||
_ => throw new JsonException($"알 수 없는 LegacyPropValue 종류: {kind}"),
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, LegacyPropValue value, JsonSerializerOptions options)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case LegacyPropValue.TextValue text:
|
||||
// 가장 흔한 형태는 축약(문자열)으로 — JSON 가독성
|
||||
writer.WriteStringValue(text.Value);
|
||||
break;
|
||||
|
||||
case LegacyPropValue.NullValue:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", "null");
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case LegacyPropValue.NestedValue nested:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", "nested");
|
||||
writer.WritePropertyName("props");
|
||||
JsonSerializer.Serialize(writer, nested.Children, options);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ItemsValue items:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", "items");
|
||||
writer.WritePropertyName("items");
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in items.Items)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("aqn", item.Aqn);
|
||||
writer.WritePropertyName("v");
|
||||
JsonSerializer.Serialize(writer, item.Value, options);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case LegacyPropValue.BinaryValue binary:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", "binary");
|
||||
writer.WriteString("b64", binary.Base64);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ReferenceValue reference:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("k", "ref");
|
||||
writer.WriteString("ref", reference.Name);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 서식 문서 직렬화 계약 — v1 주 포맷은 레거시 XML(<see cref="LegacyXmlSerializer"/>),
|
||||
/// 부 포맷은 JSON(<see cref="FormJsonSerializer"/>). 추후 JSON 주 포맷 승격 시 교체 지점.
|
||||
/// </summary>
|
||||
public interface IDesignSerializer
|
||||
{
|
||||
/// <summary>문자열 콘텐츠를 서식 문서로 역직렬화</summary>
|
||||
FormDocument Read(string content);
|
||||
|
||||
/// <summary>서식 문서를 문자열로 직렬화</summary>
|
||||
string Write(FormDocument document);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 TypeConverter invariant 문자열 포맷 도우미.
|
||||
/// 읽기는 관용적으로, 쓰기는 레거시 원문 형식("X, Y" / "이름, 11.25pt, style=Bold")과 동일하게.
|
||||
/// </summary>
|
||||
public static class LegacyFormat
|
||||
{
|
||||
#region Point / Size
|
||||
/// <summary>"336, 1064" 형식 파싱 — 실패 시 (0,0)</summary>
|
||||
public static (double X, double Y) ParsePoint(string? text)
|
||||
{
|
||||
if (TryParsePair(text, out var x, out var y))
|
||||
{
|
||||
return (x, y);
|
||||
}
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
/// <summary>"174, 18" 형식 파싱 — 실패 시 (0,0)</summary>
|
||||
public static (double W, double H) ParseSize(string? text) => ParsePoint(text);
|
||||
|
||||
/// <summary>Point/Size invariant 문자열 생성 — 레거시와 동일한 "X, Y" (정수 반올림)</summary>
|
||||
public static string FormatPair(double a, double b)
|
||||
=> string.Create(CultureInfo.InvariantCulture, $"{(int)Math.Round(a)}, {(int)Math.Round(b)}");
|
||||
|
||||
private static bool TryParsePair(string? text, out double a, out double b)
|
||||
{
|
||||
a = 0;
|
||||
b = 0;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var parts = text.Split(',');
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return double.TryParse(parts[0].Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out a)
|
||||
&& double.TryParse(parts[1].Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out b);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Font
|
||||
/// <summary>
|
||||
/// 레거시 FontConverter invariant 문자열 파싱 — "굴림, 11.25pt, style=Bold, Underline".
|
||||
/// 실패 시 기본(굴림 9pt) 반환.
|
||||
/// </summary>
|
||||
public static LegacyFont ParseFont(string? text)
|
||||
{
|
||||
var font = new LegacyFont();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return font;
|
||||
}
|
||||
|
||||
var parts = text.Split(',');
|
||||
if (parts.Length > 0)
|
||||
{
|
||||
font.Family = parts[0].Trim();
|
||||
}
|
||||
|
||||
for (var i = 1; i < parts.Length; i++)
|
||||
{
|
||||
var part = parts[i].Trim();
|
||||
if (part.EndsWith("pt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (double.TryParse(part[..^2], NumberStyles.Number, CultureInfo.InvariantCulture, out var size))
|
||||
{
|
||||
font.SizePt = size;
|
||||
}
|
||||
}
|
||||
else if (part.StartsWith("style=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ApplyStyle(font, part[6..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// "style=Bold, Italic" 처럼 콤마로 이어진 후속 스타일 토큰
|
||||
ApplyStyle(font, part);
|
||||
}
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
/// <summary>레거시 FontConverter invariant 형식으로 생성 — "이름, 11.25pt[, style=Bold, Italic]"</summary>
|
||||
public static string FormatFont(LegacyFont font)
|
||||
{
|
||||
var size = font.SizePt.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
var styles = new List<string>(4);
|
||||
if (font.Bold) { styles.Add("Bold"); }
|
||||
if (font.Italic) { styles.Add("Italic"); }
|
||||
if (font.Underline) { styles.Add("Underline"); }
|
||||
if (font.Strikeout) { styles.Add("Strikeout"); }
|
||||
|
||||
return styles.Count == 0
|
||||
? $"{font.Family}, {size}pt"
|
||||
: $"{font.Family}, {size}pt, style={string.Join(", ", styles)}";
|
||||
}
|
||||
|
||||
private static void ApplyStyle(LegacyFont font, string token)
|
||||
{
|
||||
switch (token.Trim())
|
||||
{
|
||||
case "Bold": font.Bold = true; break;
|
||||
case "Italic": font.Italic = true; break;
|
||||
case "Underline": font.Underline = true; break;
|
||||
case "Strikeout": font.Strikeout = true; break;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Color
|
||||
/// <summary>
|
||||
/// 레거시 ColorConverter invariant 문자열 파싱 — "224, 224, 224" / "255, 0, 0, 0"(ARGB) / "White" / "ControlDarkDark".
|
||||
/// 반환: (A,R,G,B). 이름 색은 System.Drawing 으로 해석, 실패 시 검정.
|
||||
/// </summary>
|
||||
public static (byte A, byte R, byte G, byte B) ParseColor(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return (255, 0, 0, 0);
|
||||
}
|
||||
|
||||
var parts = text.Split(',');
|
||||
if (parts.Length is 3 or 4 && byte.TryParse(parts[0].Trim(), out _))
|
||||
{
|
||||
var values = new byte[parts.Length];
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (!byte.TryParse(parts[i].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out values[i]))
|
||||
{
|
||||
return (255, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
return parts.Length == 3
|
||||
? ((byte)255, values[0], values[1], values[2])
|
||||
: (values[0], values[1], values[2], values[3]);
|
||||
}
|
||||
|
||||
// 명명색/시스템색 — System.Drawing 의 KnownColor 해석 사용(레거시와 동일 의미론)
|
||||
var named = System.Drawing.Color.FromName(text.Trim());
|
||||
if (named.IsKnownColor || named.A != 0 || named.R != 0 || named.G != 0 || named.B != 0)
|
||||
{
|
||||
return (named.A, named.R, named.G, named.B);
|
||||
}
|
||||
return (255, 0, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>ColorConverter invariant 형식으로 생성 — 불투명 색은 "R, G, B", 반투명은 "A, R, G, B"</summary>
|
||||
public static string FormatColor(byte a, byte r, byte g, byte b)
|
||||
=> a == 255
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{r}, {g}, {b}")
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{a}, {r}, {g}, {b}");
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>레거시 폰트 표현 — WinForms Font invariant 문자열과 1:1</summary>
|
||||
public sealed class LegacyFont
|
||||
{
|
||||
/// <summary>글꼴 패밀리명</summary>
|
||||
public string Family { get; set; } = "굴림";
|
||||
|
||||
/// <summary>크기(pt)</summary>
|
||||
public double SizePt { get; set; } = 9;
|
||||
|
||||
/// <summary>굵게</summary>
|
||||
public bool Bold { get; set; }
|
||||
|
||||
/// <summary>기울임</summary>
|
||||
public bool Italic { get; set; }
|
||||
|
||||
/// <summary>밑줄</summary>
|
||||
public bool Underline { get; set; }
|
||||
|
||||
/// <summary>취소선</summary>
|
||||
public bool Strikeout { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 컨트롤 타입(AQN) ↔ 중립 타입명 매핑의 단일 출처.
|
||||
/// 읽기: AQN 의 클래스 단축명을 정확 일치로 매핑(미지 타입은 "Placeholder").
|
||||
/// 쓰기: 신규 생성 컨트롤의 AQN 을 실DB 샘플에서 확인된 원문 형식으로 생성.
|
||||
/// </summary>
|
||||
public static class LegacyTypeCatalog
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>미지원/미지 타입의 중립 타입명</summary>
|
||||
public const string PlaceholderType = "Placeholder";
|
||||
|
||||
/// <summary>페이지 루트 클래스 단축명</summary>
|
||||
public const string HostClassName = "MDesignerHost";
|
||||
|
||||
/// <summary>페이지 루트 AQN (실샘플 sheetdesign.xml 원문 확인)</summary>
|
||||
public const string HostAqn =
|
||||
"M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
|
||||
// 레거시 클래스 단축명 → 중립 타입명 (정확 일치 — contains 순서 버그 원천 차단)
|
||||
private static readonly Dictionary<string, string> legacyToNeutral = new(StringComparer.Ordinal)
|
||||
{
|
||||
["TextBox"] = "TextBox",
|
||||
["Label"] = "Label",
|
||||
["MFormatLabel"] = "Label",
|
||||
["MSequence"] = "Label",
|
||||
["CheckBox"] = "CheckBox",
|
||||
["CheckLabel"] = "CheckBox",
|
||||
["RadioButton"] = "RadioButton",
|
||||
["ComboBox"] = "ComboBox",
|
||||
["ListBox"] = "ListBox",
|
||||
["MCheckedListBox"] = "CheckList",
|
||||
["DateTimePicker"] = "DateTimePicker",
|
||||
["MaskedTextBox"] = "MaskedTextBox",
|
||||
["Panel"] = "Panel",
|
||||
["Panel2"] = "Panel",
|
||||
["MLayerPanel"] = "Panel",
|
||||
["MExpandablePanel"] = "Panel",
|
||||
["GroupBox"] = "GroupBox",
|
||||
["MLine"] = "Line",
|
||||
["MPictureBox"] = "PictureBox",
|
||||
["MCalcBox"] = "CalcBox",
|
||||
["MButton"] = "Button",
|
||||
["MDataTable"] = "DataTable",
|
||||
// Spread 는 읽기 매핑만(렌더) — E_SpdMst 디자인 없이는 신규 생성 불가하므로 쓰기 매핑/팔레트 미등록
|
||||
["Spread"] = "Spread",
|
||||
};
|
||||
|
||||
// 중립 타입명 → 신규 생성 시 사용할 레거시 클래스 단축명
|
||||
private static readonly Dictionary<string, string> neutralToLegacyClass = new(StringComparer.Ordinal)
|
||||
{
|
||||
["TextBox"] = "TextBox",
|
||||
["Label"] = "Label",
|
||||
["CheckBox"] = "CheckBox",
|
||||
["RadioButton"] = "RadioButton",
|
||||
["ComboBox"] = "ComboBox",
|
||||
["ListBox"] = "ListBox",
|
||||
["CheckList"] = "MCheckedListBox",
|
||||
["DateTimePicker"] = "DateTimePicker",
|
||||
["MaskedTextBox"] = "MaskedTextBox",
|
||||
["Panel"] = "Panel",
|
||||
["GroupBox"] = "GroupBox",
|
||||
["Line"] = "MLine",
|
||||
["PictureBox"] = "MPictureBox",
|
||||
["CalcBox"] = "MCalcBox",
|
||||
["Button"] = "MButton",
|
||||
["DataTable"] = "MDataTable",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>AQN 에서 클래스 단축명 추출 (예: "M.EMR.UserControl.TextBox, ..." → "TextBox")</summary>
|
||||
public static string ShortClassName(string aqn)
|
||||
{
|
||||
var comma = aqn.IndexOf(',');
|
||||
var fullName = comma >= 0 ? aqn[..comma] : aqn;
|
||||
var dot = fullName.LastIndexOf('.');
|
||||
return (dot >= 0 ? fullName[(dot + 1)..] : fullName).Trim();
|
||||
}
|
||||
|
||||
/// <summary>AQN 을 중립 타입명으로 해석 — 미지 타입은 Placeholder</summary>
|
||||
public static string Resolve(string aqn)
|
||||
{
|
||||
var shortName = ShortClassName(aqn);
|
||||
return legacyToNeutral.TryGetValue(shortName, out var neutral) ? neutral : PlaceholderType;
|
||||
}
|
||||
|
||||
/// <summary>페이지 루트(MDesignerHost) 타입인지 판정</summary>
|
||||
public static bool IsHost(string aqn) => ShortClassName(aqn) == HostClassName;
|
||||
|
||||
/// <summary>중립 타입명의 신규 생성용 AQN — 미지원 타입이면 null</summary>
|
||||
public static string? AqnOf(string neutralType)
|
||||
{
|
||||
if (!neutralToLegacyClass.TryGetValue(neutralType, out var legacyClass))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return $"M.EMR.UserControl.{legacyClass}, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 서식 XML 직렬화기 — v1 주 포맷.
|
||||
/// 포맷 원본: [003]DesignerHosting\clsBasicDesignerLoader.vb (읽기 LoadObject/ReadProperty·쓰기 WriteObjectDesignInfo),
|
||||
/// 병합 규약: clsCommonLib.Xml_MergeXmlDocument (Sheet 루트 1개에 페이지당 MDesignerHost Object N개).
|
||||
/// 모든 Property 를 PropBag 으로 무손실 왕복하며, 쓰기 시 Bounds 를 Location/Size/LocationOnBase 로 동기화한다.
|
||||
/// </summary>
|
||||
public sealed class LegacyXmlSerializer : IDesignSerializer
|
||||
{
|
||||
#region Methods - Read
|
||||
/// <summary>레거시 XML 을 서식 문서로 역직렬화 — 파싱 경고는 document.Meta.ReadWarnings 에 수집</summary>
|
||||
public FormDocument Read(string content)
|
||||
{
|
||||
var document = new FormDocument();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
document.Pages.Add(CreateEmptyPage(1));
|
||||
return document;
|
||||
}
|
||||
|
||||
// 레거시 로더(clsBasicDesignerLoader)와 동일하게 PreserveWhitespace=false 로 파싱.
|
||||
// CheckCharacters=false: 레거시 데이터에 제어문자(예: Spread 구분자 0x05)가 원문 포함된 사례가 있어
|
||||
// 구식 XmlTextReader(무검사)와 동일하게 관용 처리한다.
|
||||
var xml = LoadLenient(content);
|
||||
|
||||
var root = xml.DocumentElement
|
||||
?? throw new InvalidDataException("서식 XML 에 루트 요소가 없습니다.");
|
||||
|
||||
foreach (XmlNode node in root.ChildNodes)
|
||||
{
|
||||
if (node.NodeType != XmlNodeType.Element || node.Name != "Object")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var pageRoot = ParseObject((XmlElement)node, document.Meta.ReadWarnings);
|
||||
document.Pages.Add(new FormPage { Root = pageRoot });
|
||||
}
|
||||
|
||||
if (document.Pages.Count == 0)
|
||||
{
|
||||
document.Meta.ReadWarnings.Add("페이지(Object) 노드를 찾지 못해 빈 페이지를 생성했습니다.");
|
||||
document.Pages.Add(CreateEmptyPage(1));
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>빈 페이지 생성 — 레거시 기본 신규 서식(MDesignerHost 720×856)과 동일</summary>
|
||||
public static FormPage CreateEmptyPage(int pageNumber)
|
||||
{
|
||||
var root = new ControlElement
|
||||
{
|
||||
Type = LegacyTypeCatalog.HostClassName,
|
||||
Id = $"MDesignerHost{pageNumber}",
|
||||
LegacyAqn = LegacyTypeCatalog.HostAqn,
|
||||
Bounds = new LayoutRect { W = 720, H = 856 },
|
||||
HasSize = true,
|
||||
};
|
||||
root.Props.SetText("BackColor", "White");
|
||||
root.Props.SetText("Name", root.Id);
|
||||
root.Props.SetText("Size", "720, 856");
|
||||
return new FormPage { Root = root };
|
||||
}
|
||||
|
||||
private ControlElement ParseObject(XmlElement node, List<string> warnings)
|
||||
{
|
||||
var aqn = node.GetAttribute("type");
|
||||
var nameAttr = node.Attributes["name"];
|
||||
var displaySequenceAttr = node.GetAttribute("DisplaySequence");
|
||||
|
||||
var element = new ControlElement
|
||||
{
|
||||
LegacyAqn = string.IsNullOrEmpty(aqn) ? null : aqn,
|
||||
Type = LegacyTypeCatalog.IsHost(aqn) ? LegacyTypeCatalog.HostClassName : LegacyTypeCatalog.Resolve(aqn),
|
||||
Id = nameAttr?.Value ?? string.Empty,
|
||||
HasNameAttr = nameAttr is not null,
|
||||
HasChildrenAttr = node.Attributes["children"] is not null,
|
||||
DisplaySequence = int.TryParse(displaySequenceAttr, out var seq) ? seq : 0,
|
||||
};
|
||||
|
||||
if (element.Type == LegacyTypeCatalog.PlaceholderType)
|
||||
{
|
||||
warnings.Add($"미지원 컨트롤 타입 보존: {element.Id} ({LegacyTypeCatalog.ShortClassName(aqn)})");
|
||||
}
|
||||
|
||||
foreach (XmlNode child in node.ChildNodes)
|
||||
{
|
||||
if (child.NodeType != XmlNodeType.Element)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
switch (child.Name)
|
||||
{
|
||||
case "Object":
|
||||
element.Children.Add(ParseObject((XmlElement)child, warnings));
|
||||
break;
|
||||
case "Property":
|
||||
var propName = ((XmlElement)child).GetAttribute("name");
|
||||
if (propName.Length == 0)
|
||||
{
|
||||
warnings.Add($"이름 없는 Property 무시: {element.Id}");
|
||||
break;
|
||||
}
|
||||
element.Props.Set(propName, ParseValue((XmlElement)child, warnings));
|
||||
break;
|
||||
default:
|
||||
warnings.Add($"알 수 없는 노드 무시: <{child.Name}> in {element.Id}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LiftBounds(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
private LegacyPropValue ParseValue(XmlElement propNode, List<string> warnings)
|
||||
{
|
||||
var elementChildren = propNode.ChildNodes.OfType<XmlElement>().ToList();
|
||||
|
||||
if (elementChildren.Count == 0)
|
||||
{
|
||||
var hasText = propNode.ChildNodes
|
||||
.Cast<XmlNode>()
|
||||
.Any(n => n.NodeType is XmlNodeType.Text or XmlNodeType.CDATA);
|
||||
return hasText
|
||||
? new LegacyPropValue.TextValue(propNode.InnerText)
|
||||
: new LegacyPropValue.NullValue();
|
||||
}
|
||||
|
||||
if (elementChildren.All(e => e.Name == "Property"))
|
||||
{
|
||||
var nested = new LegacyPropValue.NestedValue();
|
||||
foreach (var child in elementChildren)
|
||||
{
|
||||
var childName = child.GetAttribute("name");
|
||||
if (childName.Length == 0)
|
||||
{
|
||||
warnings.Add("이름 없는 중첩 Property 무시");
|
||||
continue;
|
||||
}
|
||||
nested.Children.Set(childName, ParseValue(child, warnings));
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
|
||||
if (elementChildren.All(e => e.Name == "Item"))
|
||||
{
|
||||
var items = new LegacyPropValue.ItemsValue();
|
||||
foreach (var child in elementChildren)
|
||||
{
|
||||
items.Items.Add(new LegacyItem
|
||||
{
|
||||
Aqn = child.GetAttribute("type"),
|
||||
Value = ParseValue(child, warnings),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
if (elementChildren.Count == 1 && elementChildren[0].Name == "Binary")
|
||||
{
|
||||
return new LegacyPropValue.BinaryValue { Base64 = elementChildren[0].InnerText };
|
||||
}
|
||||
|
||||
if (elementChildren.Count == 1 && elementChildren[0].Name == "Reference")
|
||||
{
|
||||
return new LegacyPropValue.ReferenceValue { Name = elementChildren[0].GetAttribute("name") };
|
||||
}
|
||||
|
||||
warnings.Add($"해석 불가 Property 형태(자식: {string.Join(",", elementChildren.Select(e => e.Name).Distinct())}) — 중첩으로 보존 시도");
|
||||
var fallback = new LegacyPropValue.NestedValue();
|
||||
foreach (var child in elementChildren.Where(e => e.Name == "Property"))
|
||||
{
|
||||
var childName = child.GetAttribute("name");
|
||||
if (childName.Length > 0)
|
||||
{
|
||||
fallback.Children.Set(childName, ParseValue(child, warnings));
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static void LiftBounds(ControlElement element)
|
||||
{
|
||||
var location = element.Props.GetText("Location");
|
||||
if (location is not null)
|
||||
{
|
||||
var (x, y) = LegacyFormat.ParsePoint(location);
|
||||
element.Bounds.X = x;
|
||||
element.Bounds.Y = y;
|
||||
element.HasLocation = true;
|
||||
}
|
||||
|
||||
var size = element.Props.GetText("Size");
|
||||
if (size is not null)
|
||||
{
|
||||
var (w, h) = LegacyFormat.ParseSize(size);
|
||||
element.Bounds.W = w;
|
||||
element.Bounds.H = h;
|
||||
element.HasSize = true;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Write
|
||||
/// <summary>서식 문서를 레거시 XML(병합형 — Sheet 루트에 페이지당 Object)로 직렬화</summary>
|
||||
public string Write(FormDocument document)
|
||||
{
|
||||
var xml = new XmlDocument();
|
||||
var sheet = xml.CreateElement("Sheet");
|
||||
var nameAttr = xml.CreateAttribute("name");
|
||||
nameAttr.Value = "SheetDesignInfo";
|
||||
sheet.Attributes.Append(nameAttr);
|
||||
xml.AppendChild(sheet);
|
||||
|
||||
foreach (var page in document.Pages)
|
||||
{
|
||||
sheet.AppendChild(WriteObject(xml, page.Root, 0, 0, isRoot: true));
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
using (var writer = XmlWriter.Create(builder, new XmlWriterSettings
|
||||
{
|
||||
OmitXmlDeclaration = true,
|
||||
Indent = false,
|
||||
// Entitize: 텍스트 내 CR 을 
 로 보존(레거시 XmlDocument 출력과 동일) — 재파싱 정규화 손실 방지
|
||||
NewLineHandling = NewLineHandling.Entitize,
|
||||
// 레거시 데이터의 제어문자(0x05 등) verbatim 재방출 허용
|
||||
CheckCharacters = false,
|
||||
}))
|
||||
{
|
||||
xml.Save(writer);
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>관용 파서 — 문자 검사 없이 XmlDocument 로드(레거시 XmlTextReader 동등)</summary>
|
||||
internal static XmlDocument LoadLenient(string content)
|
||||
{
|
||||
var xml = new XmlDocument();
|
||||
using var reader = XmlReader.Create(new StringReader(content), new XmlReaderSettings
|
||||
{
|
||||
CheckCharacters = false,
|
||||
});
|
||||
xml.Load(reader);
|
||||
return xml;
|
||||
}
|
||||
|
||||
private XmlElement WriteObject(XmlDocument xml, ControlElement element, double absX, double absY, bool isRoot)
|
||||
{
|
||||
SyncBoundsToProps(element, absX, absY, isRoot);
|
||||
|
||||
var node = xml.CreateElement("Object");
|
||||
|
||||
// 어트리뷰트 순서는 레거시 writer 와 동일: type → name → children → DisplaySequence
|
||||
var aqn = element.LegacyAqn ?? LegacyTypeCatalog.AqnOf(element.Type)
|
||||
?? throw new InvalidOperationException($"타입 '{element.Type}' 의 레거시 AQN 을 결정할 수 없습니다: {element.Id}");
|
||||
AppendAttr(xml, node, "type", aqn);
|
||||
|
||||
if (element.HasNameAttr && element.Id.Length > 0)
|
||||
{
|
||||
AppendAttr(xml, node, "name", element.Id);
|
||||
}
|
||||
if (element.HasChildrenAttr)
|
||||
{
|
||||
AppendAttr(xml, node, "children", "Controls");
|
||||
}
|
||||
AppendAttr(xml, node, "DisplaySequence", element.DisplaySequence.ToString());
|
||||
|
||||
// 레거시 writer 규약: 자식 Object 먼저, Property 나중
|
||||
foreach (var child in element.Children)
|
||||
{
|
||||
node.AppendChild(WriteObject(xml, child, absX + child.Bounds.X, absY + child.Bounds.Y, isRoot: false));
|
||||
}
|
||||
|
||||
foreach (var (name, value) in element.Props)
|
||||
{
|
||||
node.AppendChild(WriteProperty(xml, name, value));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounds → Props(Location/Size/LocationOnBase/Name) 동기화 — 레거시 writer 의 재계산 동작과 동일.
|
||||
/// XML 에서 읽어온 컨트롤(LegacyAqn 보유)은 있던 키만 갱신(순수 보존),
|
||||
/// 신규 생성 컨트롤(LegacyAqn 없음)에는 레거시 필수 속성(LocationOnBase/Name — 실샘플 117/117 보유)을 추가한다.
|
||||
/// </summary>
|
||||
private static void SyncBoundsToProps(ControlElement element, double absX, double absY, bool isRoot)
|
||||
{
|
||||
var isNew = element.LegacyAqn is null;
|
||||
|
||||
if (!isRoot)
|
||||
{
|
||||
if (element.HasLocation || element.Bounds.X != 0 || element.Bounds.Y != 0)
|
||||
{
|
||||
element.Props.SetText("Location", LegacyFormat.FormatPair(element.Bounds.X, element.Bounds.Y));
|
||||
element.HasLocation = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (element.HasSize || element.Bounds.W != 0 || element.Bounds.H != 0)
|
||||
{
|
||||
element.Props.SetText("Size", LegacyFormat.FormatPair(element.Bounds.W, element.Bounds.H));
|
||||
element.HasSize = true;
|
||||
}
|
||||
|
||||
// LocationOnBase 는 레거시 writer 가 저장 시마다 재계산(부모 절대좌표 누적)
|
||||
if (element.Props.Contains("LocationOnBase") || (!isRoot && isNew))
|
||||
{
|
||||
element.Props.SetText("LocationOnBase", LegacyFormat.FormatPair(absX, absY));
|
||||
}
|
||||
|
||||
// Name Property 는 name 어트리뷰트와 동일 값 유지(개명 동기화)
|
||||
if (element.Id.Length > 0 && (element.Props.Contains("Name") || isNew))
|
||||
{
|
||||
element.Props.SetText("Name", element.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private XmlElement WriteProperty(XmlDocument xml, string name, LegacyPropValue value)
|
||||
{
|
||||
var node = xml.CreateElement("Property");
|
||||
AppendAttr(xml, node, "name", name);
|
||||
WriteValueInto(xml, node, value);
|
||||
return node;
|
||||
}
|
||||
|
||||
private void WriteValueInto(XmlDocument xml, XmlElement node, LegacyPropValue value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case LegacyPropValue.TextValue text:
|
||||
node.InnerText = text.Value;
|
||||
break;
|
||||
|
||||
case LegacyPropValue.NullValue:
|
||||
break;
|
||||
|
||||
case LegacyPropValue.NestedValue nested:
|
||||
foreach (var (childName, childValue) in nested.Children)
|
||||
{
|
||||
node.AppendChild(WriteProperty(xml, childName, childValue));
|
||||
}
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ItemsValue items:
|
||||
foreach (var item in items.Items)
|
||||
{
|
||||
var itemNode = xml.CreateElement("Item");
|
||||
AppendAttr(xml, itemNode, "type", item.Aqn);
|
||||
WriteValueInto(xml, itemNode, item.Value);
|
||||
node.AppendChild(itemNode);
|
||||
}
|
||||
break;
|
||||
|
||||
case LegacyPropValue.BinaryValue binary:
|
||||
var binaryNode = xml.CreateElement("Binary");
|
||||
binaryNode.InnerText = binary.Base64;
|
||||
node.AppendChild(binaryNode);
|
||||
break;
|
||||
|
||||
case LegacyPropValue.ReferenceValue reference:
|
||||
var referenceNode = xml.CreateElement("Reference");
|
||||
AppendAttr(xml, referenceNode, "name", reference.Name);
|
||||
node.AppendChild(referenceNode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendAttr(XmlDocument xml, XmlElement node, string name, string value)
|
||||
{
|
||||
var attr = xml.CreateAttribute(name);
|
||||
attr.Value = value;
|
||||
node.Attributes.Append(attr);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
|
||||
namespace SheetMe.Core.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// XML 의미론 비교 — 레거시 로더가 보는 의미가 같은지 검사.
|
||||
/// 요소명·어트리뷰트·자식 요소 순서·텍스트를 비교하고, 빈 요소의 self-closing 여부와 요소 사이 공백은 무시한다.
|
||||
/// (왕복 테스트와 실DB 일괄 스모크가 공유)
|
||||
/// </summary>
|
||||
public static class XmlSemanticDiff
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>두 XML 문자열의 의미 차이 목록 — 완전 동일하면 빈 목록 (제어문자 관용 파싱)</summary>
|
||||
public static List<string> Compare(string expectedXml, string actualXml, int maxDiffs = 30)
|
||||
{
|
||||
var expected = LegacyXmlSerializer.LoadLenient(expectedXml);
|
||||
var actual = LegacyXmlSerializer.LoadLenient(actualXml);
|
||||
|
||||
var diffs = new List<string>();
|
||||
CompareElements(expected.DocumentElement!, actual.DocumentElement!, "/", diffs, maxDiffs);
|
||||
return diffs;
|
||||
}
|
||||
|
||||
private static void CompareElements(XmlElement expected, XmlElement actual, string path, List<string> diffs, int maxDiffs)
|
||||
{
|
||||
if (diffs.Count >= maxDiffs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPath = $"{path}{expected.Name}[{Describe(expected)}]";
|
||||
|
||||
if (expected.Name != actual.Name)
|
||||
{
|
||||
diffs.Add($"{currentPath}: 요소명 다름 — 기대 <{expected.Name}> 실제 <{actual.Name}>");
|
||||
return;
|
||||
}
|
||||
|
||||
var expectedAttrs = expected.Attributes.Cast<XmlAttribute>().ToDictionary(a => a.Name, a => a.Value);
|
||||
var actualAttrs = actual.Attributes.Cast<XmlAttribute>().ToDictionary(a => a.Name, a => a.Value);
|
||||
foreach (var (name, value) in expectedAttrs)
|
||||
{
|
||||
if (!actualAttrs.TryGetValue(name, out var actualValue))
|
||||
{
|
||||
diffs.Add($"{currentPath}: 어트리뷰트 누락 — {name}=\"{value}\"");
|
||||
}
|
||||
else if (actualValue != value)
|
||||
{
|
||||
diffs.Add($"{currentPath}: 어트리뷰트 값 다름 — {name} 기대 \"{value}\" 실제 \"{actualValue}\"");
|
||||
}
|
||||
}
|
||||
foreach (var name in actualAttrs.Keys.Where(k => !expectedAttrs.ContainsKey(k)))
|
||||
{
|
||||
diffs.Add($"{currentPath}: 어트리뷰트 추가됨 — {name}=\"{actualAttrs[name]}\"");
|
||||
}
|
||||
|
||||
var expectedElements = expected.ChildNodes.OfType<XmlElement>().ToList();
|
||||
var actualElements = actual.ChildNodes.OfType<XmlElement>().ToList();
|
||||
|
||||
if (expectedElements.Count != actualElements.Count)
|
||||
{
|
||||
diffs.Add($"{currentPath}: 자식 요소 수 다름 — 기대 {expectedElements.Count} 실제 {actualElements.Count} " +
|
||||
$"(기대: {Summarize(expectedElements)} / 실제: {Summarize(actualElements)})");
|
||||
}
|
||||
|
||||
var count = Math.Min(expectedElements.Count, actualElements.Count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
CompareElements(expectedElements[i], actualElements[i], currentPath + "/", diffs, maxDiffs);
|
||||
if (diffs.Count >= maxDiffs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedElements.Count == 0 && actualElements.Count == 0)
|
||||
{
|
||||
var expectedText = GetMeaningfulText(expected);
|
||||
var actualText = GetMeaningfulText(actual);
|
||||
if (expectedText != actualText)
|
||||
{
|
||||
diffs.Add($"{currentPath}: 텍스트 다름 — 기대 \"{Truncate(expectedText)}\" 실제 \"{Truncate(actualText)}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 전용 텍스트는 레거시 로더에서 null 로 취급 — "" 와 동일시
|
||||
private static string GetMeaningfulText(XmlElement element)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
foreach (XmlNode node in element.ChildNodes)
|
||||
{
|
||||
if (node.NodeType is XmlNodeType.Text or XmlNodeType.CDATA)
|
||||
{
|
||||
text.Append(node.Value);
|
||||
}
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static string Describe(XmlElement element)
|
||||
=> element.GetAttribute("name") is { Length: > 0 } name ? name : element.Name;
|
||||
|
||||
private static string Summarize(List<XmlElement> elements)
|
||||
=> string.Join(",", elements.Take(8).Select(Describe)) + (elements.Count > 8 ? ",..." : string.Empty);
|
||||
|
||||
private static string Truncate(string value)
|
||||
=> value.Length > 60 ? value[..60] + "..." : value;
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>SheetMe.Core</RootNamespace>
|
||||
<AssemblyName>SheetMe.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 레거시 XML 속성값(Font/Color/Point 등) invariant 문자열 정합용 — 로컬 캐시 보유 버전 고정 -->
|
||||
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace SheetMe.Data.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 데이터 계층 설정 — appsettings.json 바인딩 모델.
|
||||
/// SaveMode 기본값은 File — 운영 레거시 테이블(E_SdgMst/E_SctMst) 쓰기는 명시적 설정 후에만 활성화된다.
|
||||
/// </summary>
|
||||
public sealed class DataConfig
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>Oracle 접속 문자열 (ConnectionStrings:His)</summary>
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>DB 프로바이더 — 기본 Oracle</summary>
|
||||
public string Provider { get; set; } = "Oracle";
|
||||
|
||||
/// <summary>저장 모드 — File(기본, .xml 파일) | Db(E_SdgMst/E_SctMst 저장)</summary>
|
||||
public string SaveMode { get; set; } = "File";
|
||||
|
||||
/// <summary>파일 모드 서식 폴더</summary>
|
||||
public string XmlFolder { get; set; } = "forms";
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>DB 저장 모드 여부</summary>
|
||||
public bool IsDbSaveMode()
|
||||
=> string.Equals(SaveMode, "Db", StringComparison.OrdinalIgnoreCase);
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using M.Framework.TableFramework;
|
||||
using M.MW.Data.EMR;
|
||||
|
||||
namespace SheetMe.Data.DataContexts;
|
||||
|
||||
/// <summary>
|
||||
/// EMR 서식 마스터 데이터 컨텍스트 — M.MW.Data.EMR 기존 엔티티 재사용(레거시 테이블과 1:1).
|
||||
/// 목록성 LINQ 조회 기반. CLOB/채번/트랜잭션 저장은 raw ODP.NET 스토어가 담당([200]SheetMe 검증 분업).
|
||||
/// </summary>
|
||||
public sealed class EmrDataContext : AbstractDataContext
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>서식 마스터 (E_ShtMst)</summary>
|
||||
public DataSet<E_ShtMst> SheetMasters { get; }
|
||||
|
||||
/// <summary>서식 디자인 버전 (E_SdgMst)</summary>
|
||||
public DataSet<E_SdgMst> SheetDesigns { get; }
|
||||
|
||||
/// <summary>서식 컨트롤 마스터 (E_SctMst)</summary>
|
||||
public DataSet<E_SctMst> SheetControls { get; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public EmrDataContext(string provider, string connectionString) : base(provider, connectionString)
|
||||
{
|
||||
SheetMasters = new(access);
|
||||
SheetDesigns = new(access);
|
||||
SheetControls = new(access);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>SheetMe.Data</RootNamespace>
|
||||
<AssemblyName>SheetMe.Data</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SheetMe.Core\SheetMe.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- [200]SheetMe에서 검증된 조합과 동일 버전 고정 (M.MW.Data.EMR: E_ShtMst/E_SdgMst/E_SctMst 엔티티) -->
|
||||
<PackageReference Include="M.Framework.DBAccess" Version="9.3.15.1" />
|
||||
<PackageReference Include="M.Framework.TableFramework" Version="6.0.13.2" />
|
||||
<PackageReference Include="M.MW.Data.EMR" Version="3.0.0.3" />
|
||||
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.26.100" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,426 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>서식 목록 1건(열기 대화상자)</summary>
|
||||
public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign);
|
||||
|
||||
/// <summary>디자인 버전 1건</summary>
|
||||
public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string UpdUid, bool Deleted);
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 테이블(E_ShtMst/E_SdgMst/E_SctMst) 기반 서식 저장소 — 레거시 디자이너와 동일 저장 의미론.
|
||||
/// 저장 흐름(bzSaveSheetDesignNControlInfo 이식, 단일 트랜잭션):
|
||||
/// ShtCneYon='Y'+활성행 존재 → 제자리 갱신(E_SctMst 는 신규 이름만 추가),
|
||||
/// 그 외 → 기존 활성행 SdgDelYon='Y' 후 신규 행 INSERT + E_SctMst 전체 재생성.
|
||||
/// 채번: 전용 시퀀스 NEXTVAL 우선, 부재 시 MAX+1 폴백(트랜잭션 내 — [200]SheetMe 검증 방식).
|
||||
/// </summary>
|
||||
public sealed class OracleLegacyFormStore
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly string connectionString;
|
||||
private readonly LegacyXmlSerializer serializer = new();
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public OracleLegacyFormStore(string connectionString)
|
||||
{
|
||||
this.connectionString = connectionString;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - 조회
|
||||
/// <summary>서식 목록 검색 — 공백제거 UPPER LIKE 토큰 AND 매칭, 스캔서식(ShtTyp='S') 제외</summary>
|
||||
public List<SheetSummary> ListSheets(string? keyword, int max = 300)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
|
||||
var where = "WHERE NVL(s.ShtTyp,' ') <> 'S'";
|
||||
var tokens = (keyword ?? string.Empty)
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
for (var i = 0; i < tokens.Length; i++)
|
||||
{
|
||||
where += $" AND (UPPER(REPLACE(s.ShtKorNam,' ','')) LIKE :k{i} OR UPPER(s.ShtCod) LIKE :k{i})";
|
||||
command.Parameters.Add(new OracleParameter($"k{i}", $"%{tokens[i].ToUpperInvariant()}%"));
|
||||
}
|
||||
|
||||
command.CommandText =
|
||||
"SELECT * FROM (" +
|
||||
" SELECT s.ShtCod, s.ShtKorNam," +
|
||||
" (SELECT COUNT(*) FROM E_SdgMst d WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') AS DesignCount" +
|
||||
$" FROM E_ShtMst s {where} ORDER BY s.ShtCod" +
|
||||
$") WHERE ROWNUM <= {max}";
|
||||
|
||||
var result = new List<SheetSummary>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
result.Add(new SheetSummary(
|
||||
reader.GetString(0),
|
||||
reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
|
||||
reader.GetDecimal(2) > 0));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>활성(비삭제 최신) 디자인 로드 — 없으면 null. FormId/Title/SourceSdgKey 채움</summary>
|
||||
public FormDocument? LoadActiveDesign(string shtCod)
|
||||
{
|
||||
using var connection = Open();
|
||||
|
||||
var sdgKey = ResolveActiveSdgKey(connection, null, shtCod);
|
||||
if (sdgKey == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k";
|
||||
command.Parameters.Add(new OracleParameter("k", sdgKey));
|
||||
var xml = command.ExecuteScalar() as string;
|
||||
if (string.IsNullOrWhiteSpace(xml))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var document = serializer.Read(xml);
|
||||
document.FormId = shtCod;
|
||||
document.Title = QueryTitle(connection, shtCod) ?? shtCod;
|
||||
document.Meta.SourceSdgKey = sdgKey;
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>활성 디자인 원문 XML — (SdgKey, XML), 없으면 null (일괄 왕복 스모크용)</summary>
|
||||
public (decimal SdgKey, string Xml)? LoadActiveDesignRaw(string shtCod)
|
||||
{
|
||||
using var connection = Open();
|
||||
var sdgKey = ResolveActiveSdgKey(connection, null, shtCod);
|
||||
if (sdgKey == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k";
|
||||
command.Parameters.Add(new OracleParameter("k", sdgKey));
|
||||
return command.ExecuteScalar() is string xml && !string.IsNullOrWhiteSpace(xml)
|
||||
? (sdgKey, xml)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>디자인 버전의 E_SctMst 행 수(검증용)</summary>
|
||||
public int CountSctRows(decimal sdgKey)
|
||||
{
|
||||
using var connection = Open();
|
||||
return Convert.ToInt32(Scalar(connection, null,
|
||||
"SELECT COUNT(*) FROM E_SctMst WHERE SctSdgKey = :k", new OracleParameter("k", sdgKey))!);
|
||||
}
|
||||
|
||||
/// <summary>서식의 제자리 갱신 여부(ShtCneYon)</summary>
|
||||
public string GetShtCneYon(string shtCod)
|
||||
{
|
||||
using var connection = Open();
|
||||
return Scalar(connection, null,
|
||||
"SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s",
|
||||
new OracleParameter("s", shtCod)) as string ?? " ";
|
||||
}
|
||||
|
||||
/// <summary>특정 버전(SdgKey) 디자인 로드 — 이력 열람/복원용. 없으면 null</summary>
|
||||
public FormDocument? LoadDesignByKey(string shtCod, decimal sdgKey)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k";
|
||||
command.Parameters.Add(new OracleParameter("k", sdgKey));
|
||||
if (command.ExecuteScalar() is not string xml || string.IsNullOrWhiteSpace(xml))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var document = serializer.Read(xml);
|
||||
document.FormId = shtCod;
|
||||
document.Title = QueryTitle(connection, shtCod) ?? shtCod;
|
||||
document.Meta.SourceSdgKey = sdgKey;
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서식의 Spread 디자인들(E_SpdMst) — 컨트롤명(SpdName) → FarPoint XML.
|
||||
/// SpdShtCod 는 CHAR 패딩 컬럼이라 TRIM 비교(실측).
|
||||
/// </summary>
|
||||
public Dictionary<string, string> LoadSpreadDesigns(string shtCod)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText =
|
||||
"SELECT TRIM(SpdName), SpdDesign FROM E_SpdMst " +
|
||||
"WHERE TRIM(SpdShtCod) = :s AND NVL(SpdDelYon,' ') <> 'Y'";
|
||||
command.Parameters.Add(new OracleParameter("s", shtCod));
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
if (!reader.IsDBNull(1))
|
||||
{
|
||||
result[reader.GetString(0)] = reader.GetString(1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>디자인 버전 이력(본문 제외)</summary>
|
||||
public List<DesignVersionInfo> ListVersions(string shtCod)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText =
|
||||
"SELECT SdgKey, NVL(SdgUpdDtm,' '), NVL(SdgUidCod,' '), NVL(SdgDelYon,' ') " +
|
||||
"FROM E_SdgMst WHERE SdgShtCod = :s ORDER BY SdgKey DESC";
|
||||
command.Parameters.Add(new OracleParameter("s", shtCod));
|
||||
|
||||
var result = new List<DesignVersionInfo>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
result.Add(new DesignVersionInfo(
|
||||
reader.GetDecimal(0), reader.GetString(1).Trim(), reader.GetString(2).Trim(),
|
||||
reader.GetString(3) == "Y"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - 저장
|
||||
/// <summary>
|
||||
/// 디자인 저장 — 레거시 동일 의미론(제자리 갱신 vs 버저닝), 단일 트랜잭션.
|
||||
/// 반환: 저장된 SdgKey.
|
||||
/// </summary>
|
||||
public decimal SaveDesign(FormDocument document, string uid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.FormId))
|
||||
{
|
||||
throw new InvalidOperationException("서식 코드(FormId)가 없습니다.");
|
||||
}
|
||||
|
||||
var mergedXml = serializer.Write(document);
|
||||
var now = DateTime.Now.ToString("yyyyMMddHHmm"); // 레거시 SystemDateTime 포맷(12자리)
|
||||
|
||||
using var connection = Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var shtCneYon = Scalar(connection, transaction,
|
||||
"SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s",
|
||||
new OracleParameter("s", document.FormId)) as string;
|
||||
if (shtCneYon is null)
|
||||
{
|
||||
throw new InvalidOperationException($"E_ShtMst 에 서식이 없습니다: {document.FormId}");
|
||||
}
|
||||
|
||||
var activeSdgKey = ResolveActiveSdgKey(connection, transaction, document.FormId);
|
||||
var updateInPlace = shtCneYon == "Y" && activeSdgKey > 0;
|
||||
|
||||
decimal sdgKey;
|
||||
if (updateInPlace)
|
||||
{
|
||||
// 제자리 갱신 — 레거시 ShtCneYon='Y' 경로
|
||||
sdgKey = activeSdgKey;
|
||||
NonQuery(connection, transaction,
|
||||
"UPDATE E_SdgMst SET SdgDesign = :d, SdgUidCod = :u, SdgUpdDtm = :t WHERE SdgKey = :k",
|
||||
new OracleParameter("d", OracleDbType.Clob) { Value = mergedXml },
|
||||
new OracleParameter("u", uid),
|
||||
new OracleParameter("t", now),
|
||||
new OracleParameter("k", sdgKey));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 기존 활성행 히스토리 처리 후 신규 행
|
||||
if (activeSdgKey > 0)
|
||||
{
|
||||
NonQuery(connection, transaction,
|
||||
"UPDATE E_SdgMst SET SdgDelYon = 'Y' WHERE SdgKey = :k",
|
||||
new OracleParameter("k", activeSdgKey));
|
||||
}
|
||||
sdgKey = NextVal(connection, transaction, "E_SDGMST_SDGKEY",
|
||||
"SELECT NVL(MAX(SdgKey),0)+1 FROM E_SdgMst");
|
||||
NonQuery(connection, transaction,
|
||||
"INSERT INTO E_SdgMst (SdgKey, SdgShtCod, SdgDesign, SdgUidCod, SdgUpdDtm, SdgDelYon, SdgDelUid, SdgDelDtm) " +
|
||||
"VALUES (:k, :s, :d, :u, :t, '', '', '')",
|
||||
new OracleParameter("k", sdgKey),
|
||||
new OracleParameter("s", document.FormId),
|
||||
new OracleParameter("d", OracleDbType.Clob) { Value = mergedXml },
|
||||
new OracleParameter("u", uid),
|
||||
new OracleParameter("t", now));
|
||||
}
|
||||
|
||||
SaveSctMst(connection, transaction, sdgKey, mergedXml, updateInPlace);
|
||||
|
||||
transaction.Commit();
|
||||
document.Meta.SourceSdgKey = sdgKey;
|
||||
document.Meta.UpdatedBy = uid;
|
||||
document.Meta.UpdatedAt = now;
|
||||
return sdgKey;
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>E_ShtMst 존재 여부</summary>
|
||||
public bool SheetExists(string shtCod)
|
||||
{
|
||||
using var connection = Open();
|
||||
return Convert.ToInt32(Scalar(connection, null,
|
||||
"SELECT COUNT(*) FROM E_ShtMst WHERE ShtCod = :s", new OracleParameter("s", shtCod))!) > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 신규 서식 최소 등록 — 실DB 디자이너 서식 관행값(ShtTyp='D', 사용/디자인/컨트롤/EMR 'Y', 열람권한 'A' 등).
|
||||
/// 상세 속성(권한/출력 등)은 레거시 기록지정보 마스터에서 후속 설정.
|
||||
/// </summary>
|
||||
public void RegisterSheet(string shtCod, string korName, string? clsCod, string uid)
|
||||
{
|
||||
if (shtCod.Length is 0 or > 10)
|
||||
{
|
||||
throw new ArgumentException("서식 코드는 1~10자여야 합니다.");
|
||||
}
|
||||
|
||||
using var connection = Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
NonQuery(connection, transaction,
|
||||
"INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " +
|
||||
"ShtUsrDesYon, ShtUseSdg, ShtUseSct, ShtUseEmr, ShtRedAth, ShtWrtTyp, ShtCneYon, ShtTraSto, " +
|
||||
"ShtEleSig, ShtUpdUid, ShtUpdDtm) " +
|
||||
"VALUES (:cod, :adp, :nam, :cls, 'D', 'Y', 'Y', 'Y', 'Y', 'Y', 'A', '9A', 'N', 'N', 'N', :uid, :dtm)",
|
||||
new OracleParameter("cod", shtCod),
|
||||
new OracleParameter("adp", DateTime.Now.ToString("yyyyMMdd")),
|
||||
new OracleParameter("nam", korName),
|
||||
new OracleParameter("cls", string.IsNullOrWhiteSpace(clsCod) ? (object)DBNull.Value : clsCod),
|
||||
new OracleParameter("uid", uid),
|
||||
new OracleParameter("dtm", DateTime.Now.ToString("yyyyMMddHHmm")));
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveSctMst(OracleConnection connection, OracleTransaction transaction,
|
||||
decimal sdgKey, string mergedXml, bool updateInPlace)
|
||||
{
|
||||
var rows = SctMstXmlWalker.Walk(mergedXml);
|
||||
|
||||
HashSet<string>? existingNames = null;
|
||||
if (updateInPlace)
|
||||
{
|
||||
existingNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
using var query = connection.CreateCommand();
|
||||
query.Transaction = transaction;
|
||||
query.BindByName = true;
|
||||
query.CommandText = "SELECT SctObjNam FROM E_SctMst WHERE SctSdgKey = :k";
|
||||
query.Parameters.Add(new OracleParameter("k", sdgKey));
|
||||
using var reader = query.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
existingNames.Add(reader.GetString(0));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
// 제자리 갱신: 기존 이름은 유지(레거시 — SctKey 보존), 신규 이름만 추가
|
||||
if (existingNames is not null && existingNames.Contains(row.ObjName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var sctKey = NextVal(connection, transaction, "E_SCTMST_SCTKEY",
|
||||
"SELECT NVL(MAX(SctKey),0)+1 FROM E_SctMst");
|
||||
NonQuery(connection, transaction,
|
||||
"INSERT INTO E_SctMst (SctKey, SctSdgKey, SctObjNam, SctObjID, SctObjTyp, SctParObj, SctObjSeq, SctObjTxt) " +
|
||||
"VALUES (:k, :sdg, :nam, :oid, :typ, :par, :seq, :txt)",
|
||||
new OracleParameter("k", sctKey),
|
||||
new OracleParameter("sdg", sdgKey),
|
||||
new OracleParameter("nam", row.ObjName),
|
||||
new OracleParameter("oid", row.ObjId),
|
||||
new OracleParameter("typ", row.ObjType),
|
||||
new OracleParameter("par", row.ParentId),
|
||||
new OracleParameter("seq", row.ObjSeq.Length == 0 ? "0" : row.ObjSeq),
|
||||
new OracleParameter("txt", row.ObjText.Length == 0 ? (object)DBNull.Value : row.ObjText));
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Private
|
||||
private OracleConnection Open()
|
||||
{
|
||||
var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
return connection;
|
||||
}
|
||||
|
||||
/// <summary>활성(비삭제 최신) SdgKey — 없으면 0 ([200] SdgKeyResolver 의미론)</summary>
|
||||
private static decimal ResolveActiveSdgKey(OracleConnection connection, OracleTransaction? transaction, string shtCod)
|
||||
{
|
||||
var value = Scalar(connection, transaction,
|
||||
"SELECT NVL((SELECT SdgKey FROM (SELECT SdgKey FROM E_SdgMst " +
|
||||
"WHERE SdgShtCod = :s AND NVL(SdgDelYon,' ') <> 'Y' ORDER BY SdgUpdDtm DESC, SdgKey DESC) " +
|
||||
"WHERE ROWNUM = 1), 0) FROM DUAL",
|
||||
new OracleParameter("s", shtCod));
|
||||
return value is null or DBNull ? 0m : Convert.ToDecimal(value);
|
||||
}
|
||||
|
||||
private static string? QueryTitle(OracleConnection connection, string shtCod)
|
||||
=> Scalar(connection, null,
|
||||
"SELECT ShtKorNam FROM E_ShtMst WHERE ShtCod = :s", new OracleParameter("s", shtCod)) as string;
|
||||
|
||||
/// <summary>채번 — 전용 시퀀스 NEXTVAL 우선, 시퀀스 부재(ORA-02289/00942) 시 MAX+1 폴백</summary>
|
||||
private static decimal NextVal(OracleConnection connection, OracleTransaction transaction, string sequence, string fallbackSql)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Convert.ToDecimal(Scalar(connection, transaction, $"SELECT {sequence}.NEXTVAL FROM DUAL")!);
|
||||
}
|
||||
catch (OracleException ex) when (ex.Number is 2289 or 942)
|
||||
{
|
||||
return Convert.ToDecimal(Scalar(connection, transaction, fallbackSql)!);
|
||||
}
|
||||
}
|
||||
|
||||
private static object? Scalar(OracleConnection connection, OracleTransaction? transaction, string sql, params OracleParameter[] parameters)
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
if (transaction is not null)
|
||||
{
|
||||
command.Transaction = transaction;
|
||||
}
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
command.Parameters.AddRange(parameters);
|
||||
return command.ExecuteScalar();
|
||||
}
|
||||
|
||||
private static void NonQuery(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters)
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
command.Parameters.AddRange(parameters);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>상용구 1건</summary>
|
||||
public sealed record RecordWordInfo(decimal Key, string Value, decimal DisplaySeq);
|
||||
|
||||
/// <summary>
|
||||
/// 서식별 상용구(E_SHTWRDMST) 저장소 — 1단계(SHTSTPLVL=1) 문구 CRUD.
|
||||
/// 런타임 팝업(MRecordWord.dtRecWordMst) 조회 쿼리와 호환: SHTCOD+SHTSTPLVL=1, SHTDSPSEQ 정렬, 루트 SHTPARKEY=0.
|
||||
/// 레거시에는 등록 UI 가 없어(INSERT 코드 부재 — DB 직접 관리) 본 디자이너가 관리 도구 역할을 겸한다.
|
||||
/// </summary>
|
||||
public sealed class RecordWordStore
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly string connectionString;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public RecordWordStore(string connectionString)
|
||||
{
|
||||
this.connectionString = connectionString;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>서식의 1단계 상용구 목록(표시순)</summary>
|
||||
public List<RecordWordInfo> List(string shtCod)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText =
|
||||
"SELECT ShtWrdKey, ShtWrdVal, NVL(ShtDspSeq, 0) FROM E_SHTWRDMST " +
|
||||
"WHERE ShtCod = :s AND ShtStpLvl = 1 ORDER BY ShtDspSeq, ShtWrdKey";
|
||||
command.Parameters.Add(new OracleParameter("s", shtCod));
|
||||
|
||||
var result = new List<RecordWordInfo>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
result.Add(new RecordWordInfo(reader.GetDecimal(0), reader.GetString(1), reader.GetDecimal(2)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 상용구 추가 — 키는 트리거 자동채번 우선(RETURNING 회수, 실측: 지정 키를 트리거가 덮어씀),
|
||||
/// 트리거 부재(ORA-01400) 시 MAX+1 명시 폴백. 반환: 실제 저장된 키.
|
||||
/// </summary>
|
||||
public decimal Add(string shtCod, string value, string uid)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var seq = Convert.ToDecimal(Scalar(connection, transaction,
|
||||
"SELECT NVL(MAX(ShtDspSeq),0)+1 FROM E_SHTWRDMST WHERE ShtCod = :s AND ShtStpLvl = 1",
|
||||
new OracleParameter("s", shtCod))!);
|
||||
|
||||
decimal key;
|
||||
try
|
||||
{
|
||||
// 트리거 채번 경로 — 실제 키를 RETURNING 으로 회수
|
||||
using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.BindByName = true;
|
||||
command.CommandText =
|
||||
"INSERT INTO E_SHTWRDMST (ShtCod, ShtParKey, ShtWrdVal, ShtStpLvl, ShtDspSeq, ShtStt, " +
|
||||
"ShtUpdUid, ShtUpdDtf, ShtFstUid, ShtFstDtf) " +
|
||||
"VALUES (:s, 0, :v, 1, :q, 'U', :u, :d, :u, :d) " +
|
||||
"RETURNING ShtWrdKey INTO :outKey";
|
||||
command.Parameters.Add(new OracleParameter("s", shtCod));
|
||||
command.Parameters.Add(new OracleParameter("v", value));
|
||||
command.Parameters.Add(new OracleParameter("q", seq));
|
||||
command.Parameters.Add(new OracleParameter("u", uid));
|
||||
command.Parameters.Add(new OracleParameter("d", Now17()));
|
||||
var outKey = new OracleParameter("outKey", OracleDbType.Decimal, System.Data.ParameterDirection.Output);
|
||||
command.Parameters.Add(outKey);
|
||||
command.ExecuteNonQuery();
|
||||
key = ((Oracle.ManagedDataAccess.Types.OracleDecimal)outKey.Value!).Value;
|
||||
}
|
||||
catch (OracleException ex) when (ex.Number == 1400)
|
||||
{
|
||||
// 트리거 없음 — MAX+1 명시 채번
|
||||
key = Convert.ToDecimal(Scalar(connection, transaction,
|
||||
"SELECT NVL(MAX(ShtWrdKey),0)+1 FROM E_SHTWRDMST")!);
|
||||
NonQuery(connection, transaction,
|
||||
"INSERT INTO E_SHTWRDMST (ShtWrdKey, ShtCod, ShtParKey, ShtWrdVal, ShtStpLvl, ShtDspSeq, ShtStt, " +
|
||||
"ShtUpdUid, ShtUpdDtf, ShtFstUid, ShtFstDtf) " +
|
||||
"VALUES (:k, :s, 0, :v, 1, :q, 'U', :u, :d, :u, :d)",
|
||||
new OracleParameter("k", key),
|
||||
new OracleParameter("s", shtCod),
|
||||
new OracleParameter("v", value),
|
||||
new OracleParameter("q", seq),
|
||||
new OracleParameter("u", uid),
|
||||
new OracleParameter("d", Now17()));
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
return key;
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>상용구 문구 수정</summary>
|
||||
public void Update(decimal key, string value, string uid)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText =
|
||||
"UPDATE E_SHTWRDMST SET ShtWrdVal = :v, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k";
|
||||
command.Parameters.Add(new OracleParameter("v", value));
|
||||
command.Parameters.Add(new OracleParameter("u", uid));
|
||||
command.Parameters.Add(new OracleParameter("d", Now17()));
|
||||
command.Parameters.Add(new OracleParameter("k", key));
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>상용구 삭제(하위 단계 포함)</summary>
|
||||
public void Remove(decimal key)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
NonQuery(connection, transaction,
|
||||
"DELETE FROM E_SHTWRDMST WHERE ShtParKey = :k", new OracleParameter("k", key));
|
||||
NonQuery(connection, transaction,
|
||||
"DELETE FROM E_SHTWRDMST WHERE ShtWrdKey = :k", new OracleParameter("k", key));
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>표시 순서 일괄 갱신 — 목록 순서대로 1..N</summary>
|
||||
public void Reorder(IReadOnlyList<decimal> orderedKeys, string uid)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < orderedKeys.Count; i++)
|
||||
{
|
||||
NonQuery(connection, transaction,
|
||||
"UPDATE E_SHTWRDMST SET ShtDspSeq = :q, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k",
|
||||
new OracleParameter("q", (decimal)(i + 1)),
|
||||
new OracleParameter("u", uid),
|
||||
new OracleParameter("d", Now17()),
|
||||
new OracleParameter("k", orderedKeys[i]));
|
||||
}
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private OracleConnection Open()
|
||||
{
|
||||
var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
return connection;
|
||||
}
|
||||
|
||||
/// <summary>SHTUPDDTF 17자리(yyyyMMddHHmmssfff)</summary>
|
||||
private static string Now17() => DateTime.Now.ToString("yyyyMMddHHmmssfff");
|
||||
|
||||
private static object? Scalar(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters)
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
command.Parameters.AddRange(parameters);
|
||||
return command.ExecuteScalar();
|
||||
}
|
||||
|
||||
private static void NonQuery(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters)
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
command.Parameters.AddRange(parameters);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Xml;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>E_SctMst 저장 행 1건 — XML 트리 순회 산출물</summary>
|
||||
public sealed class SctRow
|
||||
{
|
||||
/// <summary>컨트롤 이름(SctObjNam)</summary>
|
||||
public string ObjName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>계층 알파벳 ID(SctObjID) — 15자 우측 공백 패딩</summary>
|
||||
public string ObjId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>타입 풀네임(SctObjTyp) — AQN 의 첫 콤마 앞</summary>
|
||||
public string ObjType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>부모 알파벳 ID(SctParObj) — 15자 우측 공백 패딩</summary>
|
||||
public string ParentId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>표시 순서(SctObjSeq) — DisplaySequence 어트리뷰트</summary>
|
||||
public string ObjSeq { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>텍스트(SctObjTxt) — Text Property 원문</summary>
|
||||
public string ObjText { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서식 XML → E_SctMst 행 목록 변환 — 레거시 bzSaveSheetDesignNControlInfo.SaveE_SctMst 의 순수 이식.
|
||||
/// 모델이 아닌 '최종 직렬화된 XML'을 순회해 100% 패리티(Spread AsTemplate 특례 포함)를 보장한다.
|
||||
/// </summary>
|
||||
public static class SctMstXmlWalker
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>병합 XML 의 전체 Object 트리를 E_SctMst 행 목록으로 (루트 MDesignerHost 포함 — 레거시 규약)</summary>
|
||||
public static List<SctRow> Walk(string mergedXml)
|
||||
{
|
||||
var rows = new List<SctRow>();
|
||||
var xml = new XmlDocument();
|
||||
xml.LoadXml(mergedXml);
|
||||
|
||||
var objId = "A";
|
||||
foreach (XmlNode node in xml.DocumentElement!.ChildNodes)
|
||||
{
|
||||
if (node.Name.Equals("Object", StringComparison.Ordinal))
|
||||
{
|
||||
AddRow(rows, (XmlElement)node, objId, string.Empty);
|
||||
WalkChildren(rows, (XmlElement)node, objId);
|
||||
}
|
||||
// 레거시 동작 보존: 시퀀스는 노드 종류와 무관하게 진행(Sheet 자식은 Object 뿐이라 동일)
|
||||
objId = NextAlphabetSequence(objId);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>알파벳 시퀀스 증가 — A→B…Z→AA→AB…, 전부 Z 면 자릿수 확장(clsCommonLib 이식)</summary>
|
||||
public static string NextAlphabetSequence(string current)
|
||||
{
|
||||
var chars = current.ToCharArray();
|
||||
var index = chars.Length - 1;
|
||||
while (index >= 0 && chars[index] == 'Z')
|
||||
{
|
||||
chars[index] = 'A';
|
||||
index--;
|
||||
}
|
||||
if (index < 0)
|
||||
{
|
||||
return "A" + new string(chars);
|
||||
}
|
||||
chars[index]++;
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private static void WalkChildren(List<SctRow> rows, XmlElement parent, string parentObjId)
|
||||
{
|
||||
var childId = "AA";
|
||||
foreach (XmlNode child in parent.ChildNodes)
|
||||
{
|
||||
if (!child.Name.Equals("Object", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
AddRow(rows, (XmlElement)child, parentObjId + childId, parentObjId);
|
||||
WalkChildren(rows, (XmlElement)child, parentObjId + childId);
|
||||
childId = NextAlphabetSequence(childId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRow(List<SctRow> rows, XmlElement node, string objId, string parentId)
|
||||
{
|
||||
var name = node.GetAttribute("name");
|
||||
var typeFullName = node.GetAttribute("type").Split(',')[0];
|
||||
var displaySequence = node.GetAttribute("DisplaySequence");
|
||||
|
||||
var text = string.Empty;
|
||||
string? spreadControlType = null;
|
||||
string? spreadItemList = null;
|
||||
|
||||
foreach (XmlNode child in node.ChildNodes)
|
||||
{
|
||||
if (!child.Name.Equals("Property", StringComparison.OrdinalIgnoreCase) || child is not XmlElement prop)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var propName = prop.GetAttribute("name");
|
||||
if (propName.Equals("Text", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text = prop.InnerText;
|
||||
}
|
||||
else if (propName.Equals("ControlType", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
spreadControlType = prop.InnerText;
|
||||
}
|
||||
else if (propName.Equals("ControlTypeAsTemplate_ItemLIST", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
spreadItemList = prop.InnerText;
|
||||
}
|
||||
}
|
||||
|
||||
rows.Add(new SctRow
|
||||
{
|
||||
ObjName = name,
|
||||
ObjId = objId.PadRight(15),
|
||||
ObjType = typeFullName,
|
||||
ParentId = parentId.PadRight(15),
|
||||
ObjSeq = displaySequence,
|
||||
ObjText = text,
|
||||
});
|
||||
|
||||
// Spread AsTemplate 특례 — 항목 목록을 개별 행으로(레거시 이식, Chr(5) 구분)
|
||||
if (string.Equals(typeFullName, "M.EMR.UserControl.Spread", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(spreadControlType, "AsTemplate", StringComparison.OrdinalIgnoreCase)
|
||||
&& spreadItemList is not null)
|
||||
{
|
||||
var items = spreadItemList.Split((char)5);
|
||||
for (var i = 0; i < items.Length - 1; i++)
|
||||
{
|
||||
rows.Add(new SctRow
|
||||
{
|
||||
ObjName = items[i],
|
||||
ObjId = i.ToString(),
|
||||
ObjType = "ItemOfSpreadAsTemplate",
|
||||
ParentId = string.Empty,
|
||||
ObjSeq = string.Empty,
|
||||
ObjText = i.ToString().PadLeft(3, '0') + "_" + items[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// 파일 기반 서식 저장소 — 레거시 XML(.xml) 파일 열기/저장.
|
||||
/// 레거시 디자이너의 XML 내보내기(TK_ExportXML) 산출물과 호환되며, DB 없는 개발 모드의 기본 저장소.
|
||||
/// </summary>
|
||||
public sealed class XmlFileFormStore
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly LegacyXmlSerializer serializer = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>XML 파일 열기 — FormId 는 파일명에서 유래</summary>
|
||||
public FormDocument LoadFile(string path)
|
||||
{
|
||||
var content = File.ReadAllText(path);
|
||||
var document = serializer.Read(content);
|
||||
document.FormId = Path.GetFileNameWithoutExtension(path);
|
||||
document.Title = document.FormId;
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>XML 파일 저장 — UTF-8(BOM 없음)</summary>
|
||||
public void SaveFile(FormDocument document, string path)
|
||||
{
|
||||
var xml = serializer.Write(document);
|
||||
File.WriteAllText(path, xml, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Application x:Class="SheetMe.Designer.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<!-- 타입별 렌더 템플릿 — 캔버스/미리보기/인쇄 공유 -->
|
||||
<ResourceDictionary Source="/Controls/ControlTemplates.xaml"/>
|
||||
<!-- [200]SheetMe 디자인 시스템 — 암시 스타일 + 키 스타일({DynamicResource B.*} 토큰 참조) -->
|
||||
<ResourceDictionary Source="/Themes/DesignerTheme.xaml"/>
|
||||
<!-- 색 토큰 — ThemeManager 가 Tokens.Dark/Light 를 Source 로 식별해 런타임 교체 -->
|
||||
<ResourceDictionary Source="/Themes/Tokens.Dark.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.Views;
|
||||
|
||||
namespace SheetMe.Designer;
|
||||
|
||||
/// <summary>
|
||||
/// 서식생성기 애플리케이션 진입점.
|
||||
/// 명령행: --render-smoke <입력.xml> <출력.png> — 첫 페이지를 오프스크린 렌더해 PNG 저장(검증용) 후 종료.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
/// <summary>기동 — 스모크 모드 분기 또는 메인 셸 표시</summary>
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--render-smoke")
|
||||
{
|
||||
var exitCode = RunRenderSmoke(e.Args[1], e.Args[2]);
|
||||
Shutdown(exitCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 2 && e.Args[0] == "--edit-smoke")
|
||||
{
|
||||
Shutdown(Diagnostics.EditSmoke.Run(e.Args[1]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 2 && e.Args[0] == "--db-smoke")
|
||||
{
|
||||
var max = e.Args.Length >= 3 && int.TryParse(e.Args[2], out var n) ? n : 30;
|
||||
Shutdown(Diagnostics.DbSmoke.RunReadSmoke(e.Args[1], max));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-save-smoke")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunSaveSmoke(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-find")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunFind(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-columns")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunColumns(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-row")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunRow(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-sample")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunSample(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-word-smoke")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunWordSmoke(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-spd")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunSpreadDump(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-xml")
|
||||
{
|
||||
Shutdown(Diagnostics.DbSmoke.RunDesignXml(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Args.Length >= 3 && e.Args[0] == "--db-render")
|
||||
{
|
||||
Shutdown(RunDbRenderSmoke(e.Args[1], e.Args[2]));
|
||||
return;
|
||||
}
|
||||
|
||||
Services.ThemeManager.LoadSaved();
|
||||
new MainView().Show();
|
||||
}
|
||||
|
||||
/// <summary>첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증)</summary>
|
||||
private static int RunRenderSmoke(string xmlPath, string pngPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var store = new Data.Stores.XmlFileFormStore();
|
||||
var document = store.LoadFile(xmlPath);
|
||||
var page = DocumentMapper.CreatePage(document.Pages[0], 0);
|
||||
|
||||
var view = new PageView { DataContext = page };
|
||||
var size = new Size(page.WidthDip, page.HeightDip);
|
||||
view.Measure(size);
|
||||
view.Arrange(new Rect(size));
|
||||
view.UpdateLayout();
|
||||
|
||||
var bitmap = new RenderTargetBitmap(
|
||||
(int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height), 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(view);
|
||||
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
using var stream = File.Create(pngPath);
|
||||
encoder.Save(stream);
|
||||
|
||||
Console.WriteLine($"렌더 스모크 완료: {pngPath} ({size.Width}x{size.Height}, " +
|
||||
$"페이지 {document.Pages.Count}, 경고 {document.Meta.ReadWarnings.Count})");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"렌더 스모크 실패: {ex}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>DB 서식 첫 페이지 오프스크린 렌더 → PNG (Spread 격자 주입 경로 포함 검증)</summary>
|
||||
private static int RunDbRenderSmoke(string shtCod, string pngPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataBusiness = new DataBusiness.FormDesignDataBusiness();
|
||||
var document = dataBusiness.OpenFromDb(shtCod);
|
||||
if (document is null)
|
||||
{
|
||||
File.WriteAllText(pngPath + ".err.txt", $"활성 디자인 없음: {shtCod}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var designer = new ViewModels.DesignerViewModel(document, dataBusiness.LoadSpreadGrids(shtCod));
|
||||
var page = designer.Pages[0];
|
||||
|
||||
var view = new PageView { DataContext = page };
|
||||
var size = new Size(page.WidthDip, page.HeightDip);
|
||||
view.Measure(size);
|
||||
view.Arrange(new Rect(size));
|
||||
view.UpdateLayout();
|
||||
|
||||
var bitmap = new RenderTargetBitmap(
|
||||
(int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height), 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(view);
|
||||
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
using var stream = File.Create(pngPath);
|
||||
encoder.Save(stream);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(pngPath + ".err.txt", ex.ToString());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,48 @@
|
||||
using System.Windows;
|
||||
using Microsoft.Xaml.Behaviors;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Behaviors;
|
||||
|
||||
/// <summary>캔버스 드롭 수신 — 팔레트 타입을 월드 좌표에 배치.</summary>
|
||||
public sealed class CanvasDropBehavior : Behavior<FrameworkElement>
|
||||
{
|
||||
#region Methods
|
||||
protected override void OnAttached()
|
||||
{
|
||||
base.OnAttached();
|
||||
AssociatedObject.AllowDrop = true;
|
||||
AssociatedObject.DragOver += OnDragOver;
|
||||
AssociatedObject.Drop += OnDrop;
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
AssociatedObject.DragOver -= OnDragOver;
|
||||
AssociatedObject.Drop -= OnDrop;
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
private void OnDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
e.Effects = e.Data.GetDataPresent(PaletteDragBehavior.DataFormat)
|
||||
? DragDropEffects.Copy
|
||||
: DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (AssociatedObject.DataContext is not DesignerViewModel designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (e.Data.GetData(PaletteDragBehavior.DataFormat) is not string type)
|
||||
{
|
||||
return;
|
||||
}
|
||||
designer.AddControlAt(type, e.GetPosition(AssociatedObject));
|
||||
e.Handled = true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using Microsoft.Xaml.Behaviors;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// 캔버스 키보드 단축키 — 텍스트 입력 요소에 포커스가 있으면 통과(입력 보호).
|
||||
/// 화살표 넛지(Shift=그리드), Del, Ctrl+A/C/V/D/Z/Y, Esc.
|
||||
/// </summary>
|
||||
public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
|
||||
{
|
||||
#region Methods
|
||||
protected override void OnAttached()
|
||||
{
|
||||
base.OnAttached();
|
||||
AssociatedObject.PreviewKeyDown += OnPreviewKeyDown;
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
AssociatedObject.PreviewKeyDown -= OnPreviewKeyDown;
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if ((AssociatedObject as FrameworkElement)?.DataContext is not DesignerViewModel designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 텍스트 입력 보호 — 포커스가 편집 컨트롤이면 단축키 라우팅 안 함
|
||||
if (Keyboard.FocusedElement is TextBoxBase or ComboBox or PasswordBox)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
|
||||
var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
|
||||
var step = shift ? designer.Snap.GridSize * 2 : 1;
|
||||
var handled = true;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Left: designer.NudgeSelection(-step, 0); break;
|
||||
case Key.Right: designer.NudgeSelection(step, 0); break;
|
||||
case Key.Up: designer.NudgeSelection(0, -step); break;
|
||||
case Key.Down: designer.NudgeSelection(0, step); break;
|
||||
case Key.Delete: designer.DeleteSelection(); break;
|
||||
case Key.Escape: designer.Interaction.Cancel(); break;
|
||||
case Key.A when ctrl: designer.SelectAllOnActivePage(); break;
|
||||
case Key.C when ctrl: designer.CopySelection(); break;
|
||||
case Key.X when ctrl: designer.CutSelection(); break;
|
||||
case Key.V when ctrl: designer.Paste(); break;
|
||||
case Key.D when ctrl: designer.CopySelection(); designer.Paste(); break;
|
||||
case Key.Z when ctrl: designer.Undo.Undo(); break;
|
||||
case Key.Y when ctrl: designer.Undo.Redo(); break;
|
||||
case Key.G when ctrl && shift: designer.UngroupSelection(); break;
|
||||
case Key.G when ctrl: designer.GroupSelection(); break;
|
||||
default: handled = false; break;
|
||||
}
|
||||
|
||||
e.Handled = handled;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using Microsoft.Xaml.Behaviors;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// 캔버스 마우스 입력 → 월드 좌표 → InteractionController 위임.
|
||||
/// World 요소(LayoutTransform 줌 하위)에 부착 — GetPosition(World)가 줌 불변 논리좌표를 반환.
|
||||
/// 로직 없음: 좌표 변환·캡처·커서 매핑만 담당.
|
||||
/// </summary>
|
||||
public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
|
||||
{
|
||||
#region Methods
|
||||
protected override void OnAttached()
|
||||
{
|
||||
base.OnAttached();
|
||||
AssociatedObject.MouseLeftButtonDown += OnMouseDown;
|
||||
AssociatedObject.MouseMove += OnMouseMove;
|
||||
AssociatedObject.MouseLeftButtonUp += OnMouseUp;
|
||||
AssociatedObject.LostMouseCapture += OnLostCapture;
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
AssociatedObject.MouseLeftButtonDown -= OnMouseDown;
|
||||
AssociatedObject.MouseMove -= OnMouseMove;
|
||||
AssociatedObject.MouseLeftButtonUp -= OnMouseUp;
|
||||
AssociatedObject.LostMouseCapture -= OnLostCapture;
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
private DesignerViewModel? Designer => AssociatedObject.DataContext as DesignerViewModel;
|
||||
|
||||
private static PointerContext ContextOf(int clickCount = 1) => new(
|
||||
Keyboard.Modifiers.HasFlag(ModifierKeys.Control),
|
||||
Keyboard.Modifiers.HasFlag(ModifierKeys.Shift),
|
||||
Keyboard.Modifiers.HasFlag(ModifierKeys.Alt),
|
||||
clickCount);
|
||||
|
||||
private void OnMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (Designer is not { } designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 키보드 포커스를 캔버스 컨테이너(Focusable 상위)로 — 단축키 수신
|
||||
FocusCanvasHost();
|
||||
|
||||
designer.Interaction.PointerDown(e.GetPosition(AssociatedObject), ContextOf(e.ClickCount));
|
||||
AssociatedObject.CaptureMouse();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (Designer is not { } designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var world = e.GetPosition(AssociatedObject);
|
||||
if (e.LeftButton == MouseButtonState.Pressed && AssociatedObject.IsMouseCaptured)
|
||||
{
|
||||
designer.Interaction.PointerMove(world, ContextOf());
|
||||
}
|
||||
AssociatedObject.Cursor = MapCursor(designer.Interaction.CursorAt(world));
|
||||
}
|
||||
|
||||
private void OnMouseUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (Designer is not { } designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
designer.Interaction.PointerUp(e.GetPosition(AssociatedObject), ContextOf());
|
||||
AssociatedObject.ReleaseMouseCapture();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnLostCapture(object sender, MouseEventArgs e)
|
||||
=> Designer?.Interaction.CancelDrag(); // 정상 업 이후에는 no-op — 선택 유지
|
||||
|
||||
private void FocusCanvasHost()
|
||||
{
|
||||
// 상위 DesignerCanvasView(Focusable)로 포커스 — 키보드 단축키 수신
|
||||
DependencyObject? current = AssociatedObject;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is FrameworkElement { Focusable: true } focusable)
|
||||
{
|
||||
focusable.Focus();
|
||||
return;
|
||||
}
|
||||
current = System.Windows.Media.VisualTreeHelper.GetParent(current);
|
||||
}
|
||||
}
|
||||
|
||||
private static Cursor MapCursor(CursorKind kind) => kind switch
|
||||
{
|
||||
CursorKind.SizeAll => Cursors.SizeAll,
|
||||
CursorKind.SizeNWSE => Cursors.SizeNWSE,
|
||||
CursorKind.SizeNESW => Cursors.SizeNESW,
|
||||
CursorKind.SizeNS => Cursors.SizeNS,
|
||||
CursorKind.SizeWE => Cursors.SizeWE,
|
||||
_ => Cursors.Arrow,
|
||||
};
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using Microsoft.Xaml.Behaviors;
|
||||
using SheetMe.Core.Catalog;
|
||||
|
||||
namespace SheetMe.Designer.Behaviors;
|
||||
|
||||
/// <summary>팔레트 항목 드래그 시작 — 5px 임계 후 DoDragDrop("SheetMe.ControlType").</summary>
|
||||
public sealed class PaletteDragBehavior : Behavior<ListBox>
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>드래그 데이터 포맷 키</summary>
|
||||
public const string DataFormat = "SheetMe.ControlType";
|
||||
|
||||
private Point downPoint;
|
||||
private ControlDescriptor? downItem;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
protected override void OnAttached()
|
||||
{
|
||||
base.OnAttached();
|
||||
AssociatedObject.PreviewMouseLeftButtonDown += OnDown;
|
||||
AssociatedObject.PreviewMouseMove += OnMove;
|
||||
AssociatedObject.MouseDoubleClick += OnDoubleClick;
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
AssociatedObject.PreviewMouseLeftButtonDown -= OnDown;
|
||||
AssociatedObject.PreviewMouseMove -= OnMove;
|
||||
AssociatedObject.MouseDoubleClick -= OnDoubleClick;
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
/// <summary>더블클릭 즉시 배치 — 레거시 툴박스 UX(활성 페이지 중앙 부근)</summary>
|
||||
private void OnDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if ((e.OriginalSource as FrameworkElement)?.DataContext is not ControlDescriptor item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Application.Current.MainWindow?.DataContext is not ViewModels.MainViewModel main
|
||||
|| main.CurrentDesigner is not { } designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
designer.AddPaletteItemAtCenter(item.Type);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
downPoint = e.GetPosition(AssociatedObject);
|
||||
downItem = (e.OriginalSource as FrameworkElement)?.DataContext as ControlDescriptor;
|
||||
}
|
||||
|
||||
private void OnMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (downItem is null || e.LeftButton != MouseButtonState.Pressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var position = e.GetPosition(AssociatedObject);
|
||||
if (Math.Abs(position.X - downPoint.X) < 5 && Math.Abs(position.Y - downPoint.Y) < 5)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var item = downItem;
|
||||
downItem = null;
|
||||
DragDrop.DoDragDrop(AssociatedObject, new DataObject(DataFormat, item.Type), DragDropEffects.Copy);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vmc="clr-namespace:SheetMe.Designer.ViewModels.Controls">
|
||||
|
||||
<!-- 타입별 렌더 템플릿 — 캔버스/미리보기/인쇄가 공유하는 단일 원천.
|
||||
레거시 WinForms 렌더 모양의 시각 근사. 콘텐츠는 히트테스트 제외(입력은 월드 파이프라인 단일 수신). -->
|
||||
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
|
||||
|
||||
<!-- 라벨 -->
|
||||
<DataTemplate DataType="{x:Type vmc:LabelViewModel}">
|
||||
<Grid Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<TextBlock Text="{Binding Text}"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" FontStyle="{Binding FontStyle}"
|
||||
Foreground="{Binding Foreground}"
|
||||
HorizontalAlignment="{Binding TextAlignment}"
|
||||
VerticalAlignment="Top"
|
||||
TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 텍스트박스 -->
|
||||
<DataTemplate DataType="{x:Type vmc:TextBoxViewModel}">
|
||||
<Border Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="BorderBrush" Value="#7F9DB9"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ShowBorder}" Value="False">
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<TextBlock Text="{Binding Text}" Margin="2,1"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" FontStyle="{Binding FontStyle}"
|
||||
Foreground="{Binding Foreground}"
|
||||
HorizontalAlignment="{Binding TextAlignment}"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="NoWrap"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Multiline}" Value="True">
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="VerticalAlignment" Value="Top"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 마스크 입력 -->
|
||||
<DataTemplate DataType="{x:Type vmc:MaskedTextBoxViewModel}">
|
||||
<Border Background="{Binding Background}" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
|
||||
<TextBlock Margin="2,1" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
Foreground="{Binding Foreground}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Text" Value="{Binding Text}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Text}" Value="">
|
||||
<Setter Property="Text" Value="{Binding Mask}"/>
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 체크박스 -->
|
||||
<DataTemplate DataType="{x:Type vmc:CheckBoxViewModel}">
|
||||
<StackPanel Orientation="Horizontal" Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<Border Width="13" Height="13" Background="White" BorderBrush="#5A5A5A" BorderThickness="1"
|
||||
VerticalAlignment="Center" Margin="0,0,4,0">
|
||||
<Path Data="M1,6 L5,10 L11,2" Stroke="#1E5AA0" StrokeThickness="2"
|
||||
Visibility="{Binding IsChecked, Converter={StaticResource BoolToVisibility}}"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Text}" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 라디오버튼 -->
|
||||
<DataTemplate DataType="{x:Type vmc:RadioButtonViewModel}">
|
||||
<StackPanel Orientation="Horizontal" Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<Grid Width="13" Height="13" VerticalAlignment="Center" Margin="0,0,4,0">
|
||||
<Ellipse Fill="White" Stroke="#5A5A5A" StrokeThickness="1"/>
|
||||
<Ellipse Width="6" Height="6" Fill="#1E5AA0"
|
||||
Visibility="{Binding IsChecked, Converter={StaticResource BoolToVisibility}}"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding Text}" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 콤보박스 -->
|
||||
<DataTemplate DataType="{x:Type vmc:ComboBoxViewModel}">
|
||||
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Right" Width="17" Background="#E1E1E1">
|
||||
<Path Data="M0,0 L8,0 L4,5 Z" Fill="#5A5A5A"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding DisplayText}" Margin="3,1" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
Foreground="{Binding Foreground}" TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 리스트박스 -->
|
||||
<DataTemplate DataType="{x:Type vmc:ListBoxViewModel}">
|
||||
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
|
||||
<ItemsControl ItemsSource="{Binding Items}" Margin="2">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" FontSize="12"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 체크리스트 -->
|
||||
<DataTemplate DataType="{x:Type vmc:CheckListViewModel}">
|
||||
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
|
||||
<ItemsControl ItemsSource="{Binding Items}" Margin="2">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border Width="11" Height="11" Background="White" BorderBrush="#5A5A5A"
|
||||
BorderThickness="1" Margin="0,1,4,1"/>
|
||||
<TextBlock Text="{Binding}" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 날짜선택 -->
|
||||
<DataTemplate DataType="{x:Type vmc:DateTimePickerViewModel}">
|
||||
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Right" Width="17" Background="#E1E1E1">
|
||||
<Path Data="M0,0 L8,0 L4,5 Z" Fill="#5A5A5A"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding DisplayText}" Margin="3,1" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
Foreground="{Binding Foreground}" TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 패널 (컨테이너 — 자식 재귀 렌더) -->
|
||||
<DataTemplate DataType="{x:Type vmc:PanelViewModel}">
|
||||
<Grid Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<Border BorderBrush="#A0A0A0">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ShowBorder}" Value="True">
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
</Border>
|
||||
<ItemsControl ItemsSource="{Binding Children}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
<Setter Property="Width" Value="{Binding Width}"/>
|
||||
<Setter Property="Height" Value="{Binding Height}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 그룹박스 (컨테이너) -->
|
||||
<DataTemplate DataType="{x:Type vmc:GroupBoxViewModel}">
|
||||
<Grid Background="{Binding Background}" IsHitTestVisible="False">
|
||||
<Border BorderBrush="#B5B5B5" BorderThickness="1" CornerRadius="3" Margin="0,7,0,0"/>
|
||||
<TextBlock Text="{Binding Text}" Margin="8,0,0,0" Padding="3,0"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
Background="{Binding Background}"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"/>
|
||||
<ItemsControl ItemsSource="{Binding Children}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
<Setter Property="Width" Value="{Binding Width}"/>
|
||||
<Setter Property="Height" Value="{Binding Height}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 선 -->
|
||||
<DataTemplate DataType="{x:Type vmc:LineViewModel}">
|
||||
<Rectangle Fill="{Binding LineBrush}" IsHitTestVisible="False"/>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 이미지 -->
|
||||
<DataTemplate DataType="{x:Type vmc:PictureBoxViewModel}">
|
||||
<Border Background="#F5F7FA" BorderBrush="#C8D0DA" BorderThickness="1" IsHitTestVisible="False">
|
||||
<TextBlock Text="{Binding Hint}" FontSize="10" Foreground="#8090A0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 계산박스 -->
|
||||
<DataTemplate DataType="{x:Type vmc:CalcBoxViewModel}">
|
||||
<Border Background="#FFFDF0" BorderBrush="#C9B458" BorderThickness="1" IsHitTestVisible="False">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Left" Text="ƒ" Margin="3,0,2,0" Foreground="#A08A2A"
|
||||
FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Text}" VerticalAlignment="Center"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
Foreground="{Binding Foreground}"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 버튼 (레거시 MButton — 표준 버튼 근사) -->
|
||||
<DataTemplate DataType="{x:Type vmc:ButtonViewModel}">
|
||||
<Border CornerRadius="3" BorderBrush="#8A9AAB" BorderThickness="1" IsHitTestVisible="False">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#FDFDFD" Offset="0"/>
|
||||
<GradientStop Color="#E8ECF0" Offset="0.5"/>
|
||||
<GradientStop Color="#D8DEE6" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding Text}"
|
||||
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
|
||||
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding ActionHint}" FontSize="8" Foreground="#7A8AA0"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,3,1"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 데이터소스 (레거시 MDataTable — 디자인 전용 배지, 런타임/인쇄 비가시) -->
|
||||
<DataTemplate DataType="{x:Type vmc:DataTableViewModel}">
|
||||
<Border Background="#EAF2FB" BorderBrush="#5B8DEF" BorderThickness="1" CornerRadius="4"
|
||||
IsHitTestVisible="False" ToolTip="{Binding QuerySummary}">
|
||||
<Grid>
|
||||
<!-- DB 원통 아이콘 -->
|
||||
<Path Fill="#5B8DEF" Stretch="Uniform" Margin="6"
|
||||
Data="M4,3 C4,1.9 7.6,1 12,1 C16.4,1 20,1.9 20,3 L20,17 C20,18.1 16.4,19 12,19 C7.6,19 4,18.1 4,17 Z M4,3 C4,4.1 7.6,5 12,5 C16.4,5 20,4.1 20,3 M4,8 C4,9.1 7.6,10 12,10 C16.4,10 20,9.1 20,8 M4,13 C4,14.1 7.6,15 12,15 C16.4,15 20,14.1 20,13"/>
|
||||
<TextBlock Text="{Binding Id}" FontSize="7" Foreground="#3E6BC4"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,1"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 표 (레거시 Spread — E_SpdMst 격자 렌더, 읽기 전용) -->
|
||||
<DataTemplate DataType="{x:Type vmc:SpreadViewModel}">
|
||||
<Border BorderBrush="#8A9AAB" BorderThickness="1" Background="White" IsHitTestVisible="False">
|
||||
<Grid>
|
||||
<!-- 격자선 -->
|
||||
<ItemsControl ItemsSource="{Binding GridLines}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Line X1="{Binding X1}" Y1="{Binding Y1}" X2="{Binding X2}" Y2="{Binding Y2}"
|
||||
Stroke="#C6CDD6" StrokeThickness="1"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 셀 텍스트(스팬 반영) -->
|
||||
<ItemsControl ItemsSource="{Binding CellViews}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Width="{Binding W}" Height="{Binding H}">
|
||||
<TextBlock Text="{Binding Text}"
|
||||
FontFamily="{Binding DataContext.FontFamily, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
FontSize="{Binding DataContext.FontSize, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" TextWrapping="Wrap"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 격자 정보 없음(파일 열기 등) 안내 -->
|
||||
<TextBlock Text="표 (Spread — DB에서 열면 격자 표시)" FontSize="10" Foreground="#8090A0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasGrid}" Value="False">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 미지원 컨트롤 자리표시 (빗금 + 원본 타입명) -->
|
||||
<DataTemplate DataType="{x:Type vmc:PlaceholderViewModel}">
|
||||
<Border BorderBrush="#D08080" BorderThickness="1" IsHitTestVisible="False">
|
||||
<Border.Background>
|
||||
<DrawingBrush TileMode="Tile" Viewport="0,0,8,8" ViewportUnits="Absolute">
|
||||
<DrawingBrush.Drawing>
|
||||
<GeometryDrawing Geometry="M0,8 L8,0">
|
||||
<GeometryDrawing.Pen>
|
||||
<Pen Brush="#30D08080" Thickness="1"/>
|
||||
</GeometryDrawing.Pen>
|
||||
</GeometryDrawing>
|
||||
</DrawingBrush.Drawing>
|
||||
</DrawingBrush>
|
||||
</Border.Background>
|
||||
<TextBlock Text="{Binding LegacyClassName}" FontSize="10" Foreground="#B06060"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
namespace SheetMe.Designer.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Lucide(ISC) 아이콘을 WPF 로 렌더 — SVG(24x24, stroke 2, round) inner 마크업을
|
||||
/// Geometry 로 파싱해 Viewbox+Path 로 그린다. 외부 패키지 불필요.
|
||||
/// </summary>
|
||||
public static class LucideIcons
|
||||
{
|
||||
private static readonly Dictionary<string, string> Data = new(StringComparer.Ordinal)
|
||||
{
|
||||
["file-text"] = """<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" />""",
|
||||
["layout-dashboard"] = """<rect width="7" height="9" x="3" y="3" rx="1" /> <rect width="7" height="5" x="14" y="3" rx="1" /> <rect width="7" height="9" x="14" y="12" rx="1" /> <rect width="7" height="5" x="3" y="16" rx="1" />""",
|
||||
["pencil-ruler"] = """<path d="M13 7 8.7 2.7a2.41 2.41 0 0 0-3.4 0L2.7 5.3a2.41 2.41 0 0 0 0 3.4L7 13" /> <path d="m8 6 2-2" /> <path d="m18 16 2-2" /> <path d="m17 11 4.3 4.3c.94.94.94 2.46 0 3.4l-2.6 2.6c-.94.94-2.46.94-3.4 0L11 17" /> <path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" />""",
|
||||
["settings"] = """<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" />""",
|
||||
["eye"] = """<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" /> <circle cx="12" cy="12" r="3" />""",
|
||||
["save"] = """<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" /> <path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" /> <path d="M7 3v4a1 1 0 0 0 1 1h7" />""",
|
||||
["folder-open"] = """<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />""",
|
||||
["plus"] = """<path d="M5 12h14" /> <path d="M12 5v14" />""",
|
||||
["trash-2"] = """<path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />""",
|
||||
["link"] = """<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />""",
|
||||
["sigma"] = """<path d="M18 7V5a1 1 0 0 0-1-1H6.5a.5.5 0 0 0-.4.8l4.5 6a2 2 0 0 1 0 2.4l-4.5 6a.5.5 0 0 0 .4.8H17a1 1 0 0 0 1-1v-2" />""",
|
||||
["table"] = """<path d="M12 3v18" /> <rect width="18" height="18" x="3" y="3" rx="2" /> <path d="M3 9h18" /> <path d="M3 15h18" />""",
|
||||
["printer"] = """<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" /> <path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6" /> <rect x="6" y="14" width="12" height="8" rx="1" />""",
|
||||
["pen-line"] = """<path d="M13 21h8" /> <path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />""",
|
||||
["building-2"] = """<path d="M10 12h4" /> <path d="M10 8h4" /> <path d="M14 21v-3a2 2 0 0 0-4 0v3" /> <path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2" /> <path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16" />""",
|
||||
["package"] = """<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" /> <path d="M12 22V12" /> <polyline points="3.29 7 12 12 20.71 7" /> <path d="m7.5 4.27 9 5.15" />""",
|
||||
["key-round"] = """<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" /> <circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />""",
|
||||
["scroll-text"] = """<path d="M15 12h-5" /> <path d="M15 8h-5" /> <path d="M19 17V5a2 2 0 0 0-2-2H4" /> <path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" />""",
|
||||
["search"] = """<path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" />""",
|
||||
["circle-check"] = """<circle cx="12" cy="12" r="10" /> <path d="m9 12 2 2 4-4" />""",
|
||||
["type"] = """<path d="M12 4v16" /> <path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" /> <path d="M9 20h6" />""",
|
||||
["text-cursor-input"] = """<path d="M5 4h1a3 3 0 0 1 3 3 3 3 0 0 1 3-3h1" /> <path d="M13 20h-1a3 3 0 0 1-3-3 3 3 0 0 1-3 3H5" /> <path d="M5 16H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h1" /> <path d="M13 8h7a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-7" /> <path d="M9 7v10" />""",
|
||||
["calendar"] = """<path d="M8 2v4" /> <path d="M16 2v4" /> <rect width="18" height="18" x="3" y="4" rx="2" /> <path d="M3 10h18" />""",
|
||||
["list"] = """<path d="M3 5h.01" /> <path d="M3 12h.01" /> <path d="M3 19h.01" /> <path d="M8 5h13" /> <path d="M8 12h13" /> <path d="M8 19h13" />""",
|
||||
["square-check"] = """<rect width="18" height="18" x="3" y="3" rx="2" /> <path d="m9 12 2 2 4-4" />""",
|
||||
["circle-dot"] = """<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="1" />""",
|
||||
["heading"] = """<path d="M6 12h12" /> <path d="M6 20V4" /> <path d="M18 20V4" />""",
|
||||
["check"] = """<path d="M20 6 9 17l-5-5" />""",
|
||||
["chevron-right"] = """<path d="m9 18 6-6-6-6" />""",
|
||||
["chevron-left"] = """<path d="m15 18-6-6 6-6" />""",
|
||||
["chevron-down"] = """<path d="m6 9 6 6 6-6" />""",
|
||||
["layout-template"] = """<rect width="18" height="7" x="3" y="3" rx="1" /> <rect width="9" height="7" x="3" y="14" rx="1" /> <rect width="5" height="7" x="16" y="14" rx="1" />""",
|
||||
["download"] = """<path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" />""",
|
||||
["pencil"] = """<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" />""",
|
||||
["folder"] = """<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />""",
|
||||
["minus"] = """<path d="M5 12h14" />""",
|
||||
["image"] = """<rect width="18" height="18" x="3" y="3" rx="2" ry="2" /> <circle cx="9" cy="9" r="2" /> <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />""",
|
||||
["mouse-pointer-click"] = """<path d="M14 4.1 12 6" /> <path d="m5.1 8-2.9-.8" /> <path d="m6 12-1.9 2" /> <path d="M7.2 2.2 8 5.1" /> <path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z" />""",
|
||||
["list-checks"] = """<path d="m3 17 2 2 4-4" /> <path d="m3 7 2 2 4-4" /> <path d="M13 6h8" /> <path d="M13 12h8" /> <path d="M13 18h8" />""",
|
||||
["box"] = """<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /> <path d="m3.3 7 8.7 5 8.7-5" /> <path d="M12 22V12" />""",
|
||||
["align-left"] = """<line x1="21" x2="3" y1="6" y2="6" /> <line x1="15" x2="3" y1="12" y2="12" /> <line x1="17" x2="3" y1="18" y2="18" />""",
|
||||
["align-center"] = """<line x1="21" x2="3" y1="6" y2="6" /> <line x1="17" x2="7" y1="12" y2="12" /> <line x1="19" x2="5" y1="18" y2="18" />""",
|
||||
["align-right"] = """<line x1="21" x2="3" y1="6" y2="6" /> <line x1="21" x2="9" y1="12" y2="12" /> <line x1="21" x2="7" y1="18" y2="18" />""",
|
||||
["bold"] = """<path d="M14 12a4 4 0 0 0 0-8H6v8" /> <path d="M15 20a4 4 0 0 0 0-8H6v8Z" />""",
|
||||
["italic"] = """<line x1="19" x2="10" y1="4" y2="4" /> <line x1="14" x2="5" y1="20" y2="20" /> <line x1="15" x2="9" y1="4" y2="20" />""",
|
||||
["underline"] = """<path d="M6 4v6a6 6 0 0 0 12 0V4" /> <line x1="4" x2="20" y1="20" y2="20" />""",
|
||||
["zoom-in"] = """<circle cx="11" cy="11" r="8" /> <line x1="21" x2="16.65" y1="21" y2="16.65" /> <line x1="11" x2="11" y1="8" y2="14" /> <line x1="8" x2="14" y1="11" y2="11" />""",
|
||||
["zoom-out"] = """<circle cx="11" cy="11" r="8" /> <line x1="21" x2="16.65" y1="21" y2="16.65" /> <line x1="8" x2="14" y1="11" y2="11" />""",
|
||||
["maximize"] = """<path d="M8 3H5a2 2 0 0 0-2 2v3" /> <path d="M21 8V5a2 2 0 0 0-2-2h-3" /> <path d="M3 16v3a2 2 0 0 0 2 2h3" /> <path d="M16 21h3a2 2 0 0 0 2-2v-3" />""",
|
||||
["eye-off"] = """<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" /> <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" /> <path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" /> <path d="m2 2 20 20" />""",
|
||||
["copy"] = """<rect width="14" height="14" x="8" y="8" rx="2" ry="2" /> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />""",
|
||||
["clipboard"] = """<rect width="8" height="4" x="8" y="2" rx="1" ry="1" /> <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />""",
|
||||
["undo-2"] = """<path d="M9 14 4 9l5-5" /> <path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11" />""",
|
||||
["redo-2"] = """<path d="m15 14 5-5-5-5" /> <path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13" />""",
|
||||
["sun"] = """<circle cx="12" cy="12" r="4" /> <path d="M12 2v2" /> <path d="M12 20v2" /> <path d="m4.93 4.93 1.41 1.41" /> <path d="m17.66 17.66 1.41 1.41" /> <path d="M2 12h2" /> <path d="M20 12h2" /> <path d="m6.34 17.66-1.41 1.41" /> <path d="m19.07 4.93-1.41 1.41" />""",
|
||||
["moon"] = """<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />""",
|
||||
["move-horizontal"] = """<polyline points="18 8 22 12 18 16" /> <polyline points="6 8 2 12 6 16" /> <line x1="2" x2="22" y1="12" y2="12" />""",
|
||||
["move-vertical"] = """<polyline points="8 18 12 22 16 18" /> <polyline points="8 6 12 2 16 6" /> <line x1="12" x2="12" y1="2" y2="22" />""",
|
||||
["align-obj-top"] = """<line x1="3" x2="21" y1="4" y2="4" /> <rect x="6" y="7" width="4" height="9" rx="1" /> <rect x="14" y="7" width="4" height="13" rx="1" />""",
|
||||
["align-obj-middle"] = """<line x1="3" x2="21" y1="12" y2="12" /> <rect x="6" y="4" width="4" height="6" rx="1" /> <rect x="14" y="14" width="4" height="6" rx="1" />""",
|
||||
["align-obj-bottom"] = """<line x1="3" x2="21" y1="20" y2="20" /> <rect x="6" y="8" width="4" height="9" rx="1" /> <rect x="14" y="4" width="4" height="13" rx="1" />""",
|
||||
["distribute-h"] = """<rect x="3" y="7" width="3.5" height="10" rx="1" /> <rect x="10.25" y="7" width="3.5" height="10" rx="1" /> <rect x="17.5" y="7" width="3.5" height="10" rx="1" />""",
|
||||
["distribute-v"] = """<rect x="7" y="3" width="10" height="3.5" rx="1" /> <rect x="7" y="10.25" width="10" height="3.5" rx="1" /> <rect x="7" y="17.5" width="10" height="3.5" rx="1" />""",
|
||||
["square"] = """<rect width="18" height="18" x="3" y="3" rx="2" />""",
|
||||
["lock"] = """<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />""",
|
||||
["unlock"] = """<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 9.9-1" />""",
|
||||
["group"] = """<path d="M3 7V5a2 2 0 0 1 2-2h2" /> <path d="M17 3h2a2 2 0 0 1 2 2v2" /> <path d="M21 17v2a2 2 0 0 1-2 2h-2" /> <path d="M7 21H5a2 2 0 0 1-2-2v-2" /> <rect width="7" height="5" x="7" y="7" rx="1" /> <rect width="7" height="5" x="10" y="12" rx="1" />""",
|
||||
["ungroup"] = """<rect width="8" height="6" x="5" y="4" rx="1" /> <rect width="8" height="6" x="11" y="14" rx="1" />""",
|
||||
["align-obj-left"] = """<line x1="4" x2="4" y1="3" y2="21" /> <rect x="7" y="6" width="9" height="4" rx="1" /> <rect x="7" y="14" width="13" height="4" rx="1" />""",
|
||||
["align-obj-center-h"] = """<line x1="12" x2="12" y1="3" y2="21" /> <rect x="6" y="6" width="12" height="4" rx="1" /> <rect x="3" y="14" width="18" height="4" rx="1" />""",
|
||||
["align-obj-right"] = """<line x1="20" x2="20" y1="3" y2="21" /> <rect x="11" y="6" width="9" height="4" rx="1" /> <rect x="7" y="14" width="13" height="4" rx="1" />""",
|
||||
["droplet"] = """<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z" />""",
|
||||
["rotate-cw"] = """<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" />""",
|
||||
["frame"] = """<line x1="22" x2="2" y1="6" y2="6" /> <line x1="22" x2="2" y1="18" y2="18" /> <line x1="6" x2="6" y1="2" y2="22" /> <line x1="18" x2="18" y1="2" y2="22" />""",
|
||||
["filter"] = """<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />""",
|
||||
["filter-x"] = """<path d="M13.013 3H2l8 9.46V19l4 2v-8.54l.9-1.055" /> <path d="m22 3-5 5" /> <path d="m17 3 5 5" />""",
|
||||
["user"] = """<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /> <circle cx="12" cy="7" r="4" />""",
|
||||
["play"] = """<polygon points="6 3 20 12 6 21 6 3" />""",
|
||||
["menu"] = """<line x1="4" x2="20" y1="12" y2="12" /> <line x1="4" x2="20" y1="6" y2="6" /> <line x1="4" x2="20" y1="18" y2="18" />""",
|
||||
["hand"] = """<path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2" /> <path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2" /> <path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8" /> <path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" />""",
|
||||
["layout-grid"] = """<rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" />""",
|
||||
};
|
||||
|
||||
/// <summary>아이콘 + 텍스트 가로 묶음 (버튼/네비 Content 용).</summary>
|
||||
public static StackPanel Label(string icon, string text, double iconSize = 15, Brush? color = null)
|
||||
{
|
||||
var sp = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
|
||||
sp.Children.Add(Icon(icon, iconSize, color));
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
var tb = new TextBlock { Text = text, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
|
||||
if (color is not null) tb.Foreground = color; // 아이콘과 같은 색을 글자에도 적용(다크 배경서 글자 안 묻히게)
|
||||
sp.Children.Add(tb);
|
||||
}
|
||||
return sp;
|
||||
}
|
||||
|
||||
public static FrameworkElement Icon(string name, double size = 16, Brush? color = null)
|
||||
{
|
||||
color ??= Brushes.Black;
|
||||
var canvas = new Canvas { Width = 24, Height = 24 };
|
||||
if (Data.TryGetValue(name, out var markup))
|
||||
{
|
||||
var (stroke, fill) = Parse(markup);
|
||||
if (stroke.Children.Count > 0)
|
||||
canvas.Children.Add(new Path { Data = stroke, Stroke = color, StrokeThickness = 2, StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round, StrokeLineJoin = PenLineJoin.Round });
|
||||
if (fill.Children.Count > 0)
|
||||
canvas.Children.Add(new Path { Data = fill, Fill = color });
|
||||
}
|
||||
return new Viewbox { Width = size, Height = size, Child = canvas, Stretch = Stretch.Uniform, SnapsToDevicePixels = true };
|
||||
}
|
||||
|
||||
private static (GeometryGroup Stroke, GeometryGroup Fill) Parse(string markup)
|
||||
{
|
||||
var stroke = new GeometryGroup();
|
||||
var fill = new GeometryGroup();
|
||||
foreach (Match m in Regex.Matches(markup, @"<(path|rect|circle|line|polyline|polygon)\b([^>]*?)/?>", RegexOptions.Singleline))
|
||||
{
|
||||
var tag = m.Groups[1].Value;
|
||||
var attrs = m.Groups[2].Value;
|
||||
var isFill = string.Equals(Attr(attrs, "fill"), "currentColor", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Geometry? g = tag switch
|
||||
{
|
||||
"path" => SafeParse(Attr(attrs, "d")),
|
||||
"rect" => new RectangleGeometry(new Rect(D(attrs, "x"), D(attrs, "y"), D(attrs, "width"), D(attrs, "height")), D(attrs, "rx"), D(attrs, "rx")),
|
||||
"circle" => new EllipseGeometry(new Point(D(attrs, "cx"), D(attrs, "cy")), D(attrs, "r"), D(attrs, "r")),
|
||||
"line" => new LineGeometry(new Point(D(attrs, "x1"), D(attrs, "y1")), new Point(D(attrs, "x2"), D(attrs, "y2"))),
|
||||
"polyline" => PolyGeo(Attr(attrs, "points"), false),
|
||||
"polygon" => PolyGeo(Attr(attrs, "points"), true),
|
||||
_ => null,
|
||||
};
|
||||
if (g is not null) (isFill ? fill : stroke).Children.Add(g);
|
||||
}
|
||||
return (stroke, fill);
|
||||
}
|
||||
|
||||
private static string Attr(string attrs, string name)
|
||||
{
|
||||
var m = Regex.Match(attrs, name + @"\s*=\s*""([^""]*)""");
|
||||
return m.Success ? m.Groups[1].Value : string.Empty;
|
||||
}
|
||||
|
||||
private static double D(string attrs, string name)
|
||||
=> double.TryParse(Attr(attrs, name), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0;
|
||||
|
||||
private static Geometry? SafeParse(string d)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(d)) return null;
|
||||
try { return Geometry.Parse(d); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static Geometry? PolyGeo(string points, bool closed)
|
||||
{
|
||||
var nums = Regex.Split(points.Trim(), @"[\s,]+").Where(s => s.Length > 0)
|
||||
.Select(s => double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0).ToList();
|
||||
if (nums.Count < 4) return null;
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i + 1 < nums.Count; i += 2)
|
||||
sb.Append(i == 0 ? "M" : " L").Append(' ').Append(nums[i].ToString(CultureInfo.InvariantCulture)).Append(',').Append(nums[i + 1].ToString(CultureInfo.InvariantCulture));
|
||||
if (closed) sb.Append(" Z");
|
||||
return SafeParse(sb.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace SheetMe.Designer.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// 팔레트 타입 → (Lucide 아이콘, 카테고리) 매핑 — [200]SheetMe 팔레트 타일 룩 이식.
|
||||
/// Core(ControlRegistry)는 UI 무관하게 유지하고 표시 계층인 여기서만 매핑한다.
|
||||
/// </summary>
|
||||
public static class PaletteIconCatalog
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly Dictionary<string, (string Icon, string Category)> Map = new(StringComparer.Ordinal)
|
||||
{
|
||||
["Label"] = ("type", "표시"),
|
||||
["PictureBox"] = ("image", "표시"),
|
||||
["Line"] = ("minus", "표시"),
|
||||
["TextBox"] = ("text-cursor-input", "입력"),
|
||||
["MaskedTextBox"] = ("text-cursor-input", "입력"),
|
||||
["ComboBox"] = ("chevron-down", "입력"),
|
||||
["ListBox"] = ("list", "입력"),
|
||||
["CheckList"] = ("list-checks", "입력"),
|
||||
["DateTimePicker"] = ("calendar", "입력"),
|
||||
["CalcBox"] = ("sigma", "입력"),
|
||||
["CheckBox"] = ("square-check", "선택"),
|
||||
["RadioButton"] = ("circle-dot", "선택"),
|
||||
["Panel"] = ("box", "컨테이너"),
|
||||
["GroupBox"] = ("package", "컨테이너"),
|
||||
["Button"] = ("mouse-pointer-click", "동작/데이터"),
|
||||
["DataTable"] = ("table", "동작/데이터"),
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>타입의 Lucide 아이콘명(미지정 타입은 square)</summary>
|
||||
public static string IconOf(string type) => Map.TryGetValue(type, out var entry) ? entry.Icon : "square";
|
||||
|
||||
/// <summary>타입의 팔레트 카테고리(그룹 헤더)</summary>
|
||||
public static string CategoryOf(string type) => Map.TryGetValue(type, out var entry) ? entry.Category : "기타";
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>팔레트 타입명 → Lucide 아이콘 비주얼(FrameworkElement) 컨버터. parameter=크기(기본 18)</summary>
|
||||
public sealed class TypeToIconConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string type)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var size = parameter is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 18;
|
||||
var brush = System.Windows.Application.Current.TryFindResource("B.Muted") as Brush ?? Brushes.Gray;
|
||||
return LucideIcons.Icon(PaletteIconCatalog.IconOf(type), size, brush);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>팔레트 타입명 → 카테고리명 컨버터(CollectionViewSource 그룹핑용)</summary>
|
||||
public sealed class TypeToCategoryConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is string type ? PaletteIconCatalog.CategoryOf(type) : "기타";
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>불리언 반전 컨버터(선택 도구 토글 = !IsHandTool 표시용)</summary>
|
||||
public sealed class InverseBoolConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is not true;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is not true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lucide 아이콘명 → 비주얼 컨버터(문서 탭 file-text, 플로팅 바 layout-grid 등 고정 아이콘용).
|
||||
/// Binding Source 에 아이콘명 문자열을 넣어 사용 — 항목마다 새 비주얼이 생성되어 공유 부모 충돌이 없다.
|
||||
/// parameter=크기(기본 14).
|
||||
/// </summary>
|
||||
public sealed class IconNameToVisualConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string name)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var size = parameter is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 14;
|
||||
var brush = System.Windows.Application.Current.TryFindResource("B.Muted") as Brush ?? Brushes.Gray;
|
||||
return LucideIcons.Icon(name, size, brush);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Data.Config;
|
||||
using SheetMe.Data.Stores;
|
||||
using SheetMe.Designer.Services;
|
||||
|
||||
namespace SheetMe.Designer.DataBusiness;
|
||||
|
||||
/// <summary>
|
||||
/// 서식 디자인 데이터 비즈니스 — 저장소(파일/DB) 접근을 ViewModel 에서 격리.
|
||||
/// 본 도구는 내부 관리용 팻 클라이언트로 저장소를 직접 호출한다(컨벤션 편차 — 계획서 명시).
|
||||
/// DB 저장은 SaveMode='Db' 설정 게이트 뒤에서만 활성화. REST 전환 시 이 계층만 교체한다.
|
||||
/// </summary>
|
||||
internal sealed class FormDesignDataBusiness
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly XmlFileFormStore fileStore = new();
|
||||
private OracleLegacyFormStore? dbStore;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>데이터 설정(appsettings.json)</summary>
|
||||
public DataConfig Config { get; } = ConfigLoader.Load();
|
||||
|
||||
/// <summary>DB 사용 가능 여부 — 접속 문자열 존재</summary>
|
||||
public bool CanUseDb => Config.ConnectionString.Length > 0;
|
||||
|
||||
/// <summary>DB 저장 허용 여부 — SaveMode='Db' 명시 설정</summary>
|
||||
public bool CanSaveToDb => CanUseDb && Config.IsDbSaveMode();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>레거시 XML 파일 열기</summary>
|
||||
public FormDocument OpenXmlFile(string path) => fileStore.LoadFile(path);
|
||||
|
||||
/// <summary>레거시 XML 파일 저장</summary>
|
||||
public void SaveXmlFile(FormDocument document, string path) => fileStore.SaveFile(document, path);
|
||||
|
||||
/// <summary>새 문서 생성 — 빈 페이지 1장(레거시 기본 720×856)</summary>
|
||||
public FormDocument CreateNew()
|
||||
{
|
||||
var document = new FormDocument
|
||||
{
|
||||
FormId = "NewSheet",
|
||||
Title = "새 서식",
|
||||
};
|
||||
document.Pages.Add(Core.Serialization.LegacyXmlSerializer.CreateEmptyPage(1));
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>DB 서식 목록 검색</summary>
|
||||
public List<SheetSummary> ListSheets(string? keyword)
|
||||
=> DbStore().ListSheets(keyword);
|
||||
|
||||
/// <summary>DB 활성 디자인 열기 — 디자인 없으면 null</summary>
|
||||
public FormDocument? OpenFromDb(string shtCod)
|
||||
=> DbStore().LoadActiveDesign(shtCod);
|
||||
|
||||
/// <summary>서식 디자인 버전 이력(E_SdgMst, 본문 제외)</summary>
|
||||
public List<DesignVersionInfo> ListVersions(string shtCod)
|
||||
=> DbStore().ListVersions(shtCod);
|
||||
|
||||
/// <summary>특정 버전 디자인 열기 — 이력 열람/복원</summary>
|
||||
public FormDocument? OpenFromDbVersion(string shtCod, decimal sdgKey)
|
||||
=> DbStore().LoadDesignByKey(shtCod, sdgKey);
|
||||
|
||||
/// <summary>서식의 Spread 격자(E_SpdMst 파싱) — 컨트롤명 → 격자 정보(파싱 실패 건 제외)</summary>
|
||||
public Dictionary<string, Core.Serialization.SpreadGridInfo> LoadSpreadGrids(string shtCod)
|
||||
{
|
||||
var result = new Dictionary<string, Core.Serialization.SpreadGridInfo>(StringComparer.Ordinal);
|
||||
foreach (var (name, xml) in DbStore().LoadSpreadDesigns(shtCod))
|
||||
{
|
||||
if (Core.Serialization.FarPointSpreadParser.Parse(xml) is { } grid)
|
||||
{
|
||||
result[name] = grid;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>DB 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — 반환 SdgKey</summary>
|
||||
public decimal SaveToDb(FormDocument document)
|
||||
{
|
||||
if (!CanSaveToDb)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요.");
|
||||
}
|
||||
return DbStore().SaveDesign(document, Environment.UserName);
|
||||
}
|
||||
|
||||
/// <summary>서식 코드 등록 여부(E_ShtMst)</summary>
|
||||
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
|
||||
|
||||
/// <summary>신규 서식 최소 등록 — 디자이너 서식 관행 기본값</summary>
|
||||
public void RegisterSheet(string shtCod, string korName, string? clsCod)
|
||||
=> DbStore().RegisterSheet(shtCod, korName, clsCod, Environment.UserName);
|
||||
|
||||
/// <summary>상용구 저장소(E_SHTWRDMST) — DB 접속 필요</summary>
|
||||
public RecordWordStore RecordWords()
|
||||
{
|
||||
if (!CanUseDb)
|
||||
{
|
||||
throw new InvalidOperationException("DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.");
|
||||
}
|
||||
return new RecordWordStore(Config.ConnectionString);
|
||||
}
|
||||
|
||||
private OracleLegacyFormStore DbStore()
|
||||
{
|
||||
if (!CanUseDb)
|
||||
{
|
||||
throw new InvalidOperationException("DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.");
|
||||
}
|
||||
return dbStore ??= new OracleLegacyFormStore(Config.ConnectionString);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
using System.IO;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Data.Stores;
|
||||
using SheetMe.Designer.Services;
|
||||
|
||||
namespace SheetMe.Designer.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// 실DB 검증 진단.
|
||||
/// --db-smoke <리포트> [N] : 읽기 전용 — 활성 디자인 N건 일괄 Read→Write 의미론 diff 통계.
|
||||
/// --db-save-smoke <서식코드> <리포트> : 쓰기 — 무변경 재저장 후 재로드 비교 + E_SctMst 행 수 검증.
|
||||
/// </summary>
|
||||
public static class DbSmoke
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>읽기 전용 일괄 왕복 스모크</summary>
|
||||
public static int RunReadSmoke(string reportPath, int maxSheets)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
var store = new OracleLegacyFormStore(config.ConnectionString);
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
|
||||
var sheets = store.ListSheets(null, max: 3000);
|
||||
lines.Add($"E_ShtMst 서식 {sheets.Count}건, 디자인 보유 {sheets.Count(s => s.HasDesign)}건");
|
||||
|
||||
var targets = sheets.Where(s => s.HasDesign).Take(maxSheets).ToList();
|
||||
var perfect = 0;
|
||||
var withWarnings = 0;
|
||||
var diffSheets = new List<string>();
|
||||
var failed = new List<string>();
|
||||
var placeholderTypes = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var sheet in targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
|
||||
if (raw is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var document = serializer.Read(raw.Value.Xml);
|
||||
var rewritten = serializer.Write(document);
|
||||
var diffs = XmlSemanticDiff.Compare(raw.Value.Xml, rewritten, maxDiffs: 5);
|
||||
|
||||
foreach (var warning in document.Meta.ReadWarnings.Where(w => w.StartsWith("미지원")))
|
||||
{
|
||||
var type = warning[(warning.IndexOf('(') + 1)..].TrimEnd(')');
|
||||
placeholderTypes[type] = placeholderTypes.GetValueOrDefault(type) + 1;
|
||||
}
|
||||
|
||||
if (diffs.Count == 0)
|
||||
{
|
||||
perfect++;
|
||||
if (document.Meta.ReadWarnings.Count > 0)
|
||||
{
|
||||
withWarnings++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
diffSheets.Add($" {sheet.ShtCod} {sheet.Name}: {diffs[0]}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed.Add($" {sheet.ShtCod} {sheet.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
lines.Add($"왕복 검사 {targets.Count}건 → 의미론 diff 0: {perfect}건 (미지원 컨트롤 보존 포함 {withWarnings}건), diff 발생: {diffSheets.Count}건, 예외: {failed.Count}건");
|
||||
if (placeholderTypes.Count > 0)
|
||||
{
|
||||
lines.Add("미지원(자리표시 보존) 타입 분포: " + string.Join(", ",
|
||||
placeholderTypes.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Key}×{kv.Value}")));
|
||||
}
|
||||
if (diffSheets.Count > 0)
|
||||
{
|
||||
lines.Add("[diff 발생 서식]");
|
||||
lines.AddRange(diffSheets.Take(20));
|
||||
}
|
||||
if (failed.Count > 0)
|
||||
{
|
||||
lines.Add("[예외 서식]");
|
||||
lines.AddRange(failed.Take(20));
|
||||
}
|
||||
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return diffSheets.Count == 0 && failed.Count == 0 ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lines.Add("실패: " + ex);
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
|
||||
public static int RunColumns(string tableName, string reportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText =
|
||||
"SELECT COLUMN_NAME, DATA_TYPE, NVL(DATA_LENGTH,0), NULLABLE " +
|
||||
"FROM USER_TAB_COLUMNS WHERE TABLE_NAME = UPPER(:t) ORDER BY COLUMN_ID";
|
||||
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("t", tableName));
|
||||
var lines = new List<string>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
lines.Add($"{reader.GetString(0),-16} {reader.GetString(1),-10} {reader.GetDecimal(2),5} " +
|
||||
$"{(reader.GetString(3) == "N" ? "NOT NULL" : "")}");
|
||||
}
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서식 마스터 행 주요 플래그 조회(--db-row) — 신규 등록 기본값 참조용(읽기 전용)</summary>
|
||||
public static int RunRow(string shtCod, string reportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText =
|
||||
"SELECT NVL(ShtTyp,'-'), NVL(ShtUseYon,'-'), NVL(ShtUsrDesYon,'-'), NVL(ShtUseSdg,'-'), NVL(ShtUseSct,'-'), " +
|
||||
"NVL(ShtUseEmr,'-'), NVL(ShtEleSig,'-'), NVL(ShtEdtAth,'-'), NVL(ShtRedAth,'-'), NVL(ShtWrtTyp,'-'), " +
|
||||
"NVL(ShtEdtTyp,'-'), NVL(ShtViwTyp,'-'), NVL(ShtClsCod,'-'), NVL(ShtCneYon,'-'), NVL(ShtTraSto,'-') " +
|
||||
"FROM E_ShtMst WHERE ShtCod = :s";
|
||||
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCod));
|
||||
using var reader = command.ExecuteReader();
|
||||
if (!reader.Read())
|
||||
{
|
||||
File.WriteAllText(reportPath, "행 없음");
|
||||
return 1;
|
||||
}
|
||||
var names = new[] { "ShtTyp", "ShtUseYon", "ShtUsrDesYon", "ShtUseSdg", "ShtUseSct", "ShtUseEmr",
|
||||
"ShtEleSig", "ShtEdtAth", "ShtRedAth", "ShtWrtTyp", "ShtEdtTyp", "ShtViwTyp", "ShtClsCod", "ShtCneYon", "ShtTraSto" };
|
||||
var lines = names.Select((n, i) => $"{n}={reader.GetString(i)}");
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용</summary>
|
||||
public static int RunSample(string tableName, string reportPath, int max = 12)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(tableName, "^[A-Za-z0-9_]{1,30}$"))
|
||||
{
|
||||
throw new ArgumentException("유효하지 않은 테이블명");
|
||||
}
|
||||
var config = ConfigLoader.Load();
|
||||
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = $"SELECT * FROM {tableName} WHERE ROWNUM <= {max}";
|
||||
var lines = new List<string>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var parts = new List<string>();
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
var value = reader.IsDBNull(i) ? "∅" : reader.GetValue(i).ToString() ?? "";
|
||||
if (value.Length > 30)
|
||||
{
|
||||
value = value[..30] + "…";
|
||||
}
|
||||
parts.Add($"{reader.GetName(i)}={value}");
|
||||
}
|
||||
lines.Add(string.Join(" | ", parts));
|
||||
}
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Spread 디자인 조회(--db-spd) — 목록 또는 특정 건 원문 덤프(읽기 전용)</summary>
|
||||
public static int RunSpreadDump(string shtCodOrAll, string reportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
if (shtCodOrAll == "*")
|
||||
{
|
||||
command.CommandText =
|
||||
"SELECT SpdShtCod, SpdName, LENGTH(SpdDesign), NVL(SpdDelYon,' ') FROM E_SpdMst ORDER BY SpdShtCod";
|
||||
var lines = new List<string>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
lines.Add($"{reader.GetString(0)}\t{reader.GetString(1)}\t{reader.GetDecimal(2):N0}자\t{(reader.GetString(3) == "Y" ? "이력" : "활성")}");
|
||||
}
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
}
|
||||
else
|
||||
{
|
||||
command.CommandText =
|
||||
"SELECT SpdDesign FROM E_SpdMst WHERE TRIM(SpdShtCod) = :s AND NVL(SpdDelYon,' ') <> 'Y' AND ROWNUM = 1";
|
||||
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCodOrAll));
|
||||
File.WriteAllText(reportPath, command.ExecuteScalar() as string ?? "(없음)");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>활성 디자인 XML 원문 덤프(--db-xml) — 읽기 전용</summary>
|
||||
public static int RunDesignXml(string shtCod, string reportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
var store = new OracleLegacyFormStore(config.ConnectionString);
|
||||
var raw = store.LoadActiveDesignRaw(shtCod);
|
||||
File.WriteAllText(reportPath,
|
||||
raw is null ? "(없음)" : $"SdgKey={raw.Value.SdgKey}{Environment.NewLine}{raw.Value.Xml}");
|
||||
return raw is null ? 1 : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서식 검색(--db-find) — 키워드 목록 출력</summary>
|
||||
public static int RunFind(string keyword, string reportPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
var store = new OracleLegacyFormStore(config.ConnectionString);
|
||||
var sheets = store.ListSheets(keyword, max: 50);
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine,
|
||||
sheets.Select(s => $"{s.ShtCod}\t{(s.HasDesign ? "디자인O" : "-")}\t{s.Name}")));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>상용구(E_SHTWRDMST) CRUD 왕복 스모크(--db-word-smoke) — 등록→조회→수정→순서→삭제(흔적 없음)</summary>
|
||||
public static int RunWordSmoke(string shtCod, string reportPath)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
var store = new RecordWordStore(config.ConnectionString);
|
||||
|
||||
// 이전 스모크 잔여 정리
|
||||
foreach (var stale in store.List(shtCod).Where(w => w.Value.StartsWith("스모크 문구")))
|
||||
{
|
||||
store.Remove(stale.Key);
|
||||
}
|
||||
|
||||
var before = store.List(shtCod).Count;
|
||||
lines.Add($"[사전] {shtCod} 상용구 {before}건");
|
||||
|
||||
var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", Environment.UserName);
|
||||
var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", Environment.UserName);
|
||||
var afterAdd = store.List(shtCod);
|
||||
var okAdd = afterAdd.Count == before + 2
|
||||
&& afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2);
|
||||
lines.Add($"[추가] 2건 → {afterAdd.Count}건 (키 {key1}, {key2}) : {(okAdd ? "통과" : "실패")}");
|
||||
|
||||
store.Update(key1, "스모크 문구 1 (수정됨)", Environment.UserName);
|
||||
var updatedValue = store.List(shtCod).FirstOrDefault(w => w.Key == key1)?.Value;
|
||||
var okUpdate = updatedValue == "스모크 문구 1 (수정됨)";
|
||||
lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "통과" : "실패")}");
|
||||
|
||||
var reordered = afterAdd.Select(w => w.Key).ToList();
|
||||
reordered.Reverse();
|
||||
store.Reorder(reordered, Environment.UserName);
|
||||
var firstKey = store.List(shtCod).FirstOrDefault()?.Key;
|
||||
var okReorder = firstKey == reordered[0];
|
||||
lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "통과" : "실패")}");
|
||||
|
||||
store.Remove(key1);
|
||||
store.Remove(key2);
|
||||
var afterRemove = store.List(shtCod).Count;
|
||||
var okRemove = afterRemove == before;
|
||||
lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "통과" : "실패")}");
|
||||
|
||||
var ok = okAdd && okUpdate && okReorder && okRemove;
|
||||
lines.Add(ok ? "결과: 통과" : "결과: 실패");
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lines.Add("실패: " + ex);
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>무변경 재저장 스모크 — 저장 경로 전체(트랜잭션/버저닝/E_SctMst) 검증</summary>
|
||||
public static int RunSaveSmoke(string shtCod, string reportPath)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
try
|
||||
{
|
||||
var config = ConfigLoader.Load();
|
||||
var store = new OracleLegacyFormStore(config.ConnectionString);
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
|
||||
var before = store.LoadActiveDesignRaw(shtCod)
|
||||
?? throw new InvalidOperationException($"활성 디자인이 없습니다: {shtCod}");
|
||||
var cneYon = store.GetShtCneYon(shtCod);
|
||||
var versionsBefore = store.ListVersions(shtCod);
|
||||
lines.Add($"[사전] {shtCod} ShtCneYon='{cneYon}' 활성 SdgKey={before.SdgKey}, 버전 {versionsBefore.Count}건, XML {before.Xml.Length:N0}자");
|
||||
|
||||
// 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신)
|
||||
var document = serializer.Read(before.Xml);
|
||||
document.FormId = shtCod;
|
||||
var savedKey = store.SaveDesign(document, Environment.UserName);
|
||||
|
||||
var after = store.LoadActiveDesignRaw(shtCod)
|
||||
?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패");
|
||||
var versionsAfter = store.ListVersions(shtCod);
|
||||
lines.Add($"[저장] SdgKey {before.SdgKey} → {savedKey} ({(cneYon == "Y" ? "제자리 갱신" : "신규 버전")}), 버전 {versionsAfter.Count}건");
|
||||
|
||||
var diffs = XmlSemanticDiff.Compare(before.Xml, after.Xml, maxDiffs: 10);
|
||||
lines.Add($"[비교] 원본 vs 재저장 활성본 의미론 diff: {diffs.Count}건");
|
||||
lines.AddRange(diffs.Select(d => " " + d));
|
||||
|
||||
var sctCount = store.CountSctRows(savedKey);
|
||||
var walkerCount = SctMstXmlWalker.Walk(after.Xml).Count;
|
||||
lines.Add($"[E_SctMst] 저장본 행 {sctCount}건 / 워커 기대 {walkerCount}건 → {(sctCount == walkerCount ? "일치" : "불일치")}");
|
||||
|
||||
if (cneYon != "Y")
|
||||
{
|
||||
var oldVersion = versionsAfter.FirstOrDefault(v => v.SdgKey == before.SdgKey);
|
||||
lines.Add($"[이력] 이전 버전 SdgKey={before.SdgKey} SdgDelYon='Y' 처리: {(oldVersion?.Deleted == true ? "확인" : "미확인!")}");
|
||||
}
|
||||
|
||||
var ok = diffs.Count == 0 && sctCount == walkerCount;
|
||||
lines.Add(ok ? "결과: 통과 — 레거시 디자이너/EMR 미리보기로 열어 최종 육안 확인 권장" : "결과: 실패");
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lines.Add("실패: " + ex);
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Designer.DataBusiness;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// 편집 코어 자동 검증(--edit-smoke) — 실제 마우스 없이 InteractionController 에 포인터 시퀀스를 주입해
|
||||
/// 선택/이동/리사이즈/마퀴/복사/붙여넣기/삭제/Undo/저장왕복을 검사한다.
|
||||
/// </summary>
|
||||
public static class EditSmoke
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>스모크 실행 — 결과를 리포트 파일에 기록, 실패 있으면 1 반환</summary>
|
||||
public static int Run(string reportPath)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var failed = 0;
|
||||
|
||||
void Check(string name, bool condition, string? detail = null)
|
||||
{
|
||||
if (!condition)
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
lines.Add($"{(condition ? "PASS" : "FAIL")} {name}{(condition || detail is null ? "" : " — " + detail)}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var business = new FormDesignDataBusiness();
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
var designer = new DesignerViewModel(business.CreateNew());
|
||||
var none = new PointerContext(false, false, false, 1);
|
||||
var alt = new PointerContext(false, false, true, 1);
|
||||
var ctrl = new PointerContext(true, false, false, 1);
|
||||
|
||||
ControlViewModel Find(string type)
|
||||
=> designer.Pages[0].Controls.First(c => c.Type == type);
|
||||
|
||||
// 1) 팔레트 배치
|
||||
designer.AddControlAt("TextBox", new Point(100, 100));
|
||||
designer.AddControlAt("Label", new Point(400, 60));
|
||||
designer.AddControlAt("CheckBox", new Point(200, 260));
|
||||
Check("배치: VM/모델 3개", designer.Pages[0].Controls.Count == 3
|
||||
&& designer.Document.Pages[0].Controls.Count == 3);
|
||||
Check("배치: 신규가 선택됨", designer.Selection.Items.Count == 1);
|
||||
|
||||
// 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖)
|
||||
var textBox = Find("TextBox");
|
||||
var clickPoint = new Point(textBox.X + textBox.Width / 2, textBox.Y + textBox.Height / 2);
|
||||
designer.Interaction.PointerDown(clickPoint, none);
|
||||
designer.Interaction.PointerUp(clickPoint, none);
|
||||
Check("클릭 선택: Primary=TextBox", designer.Selection.Primary == textBox);
|
||||
|
||||
// 3) 드래그 이동(Alt=자유 이동 — 결정적 검증)
|
||||
var origX = textBox.X;
|
||||
var origY = textBox.Y;
|
||||
designer.Interaction.PointerDown(clickPoint, none);
|
||||
designer.Interaction.PointerMove(new Point(clickPoint.X + 33, clickPoint.Y + 47), alt);
|
||||
designer.Interaction.PointerUp(new Point(clickPoint.X + 33, clickPoint.Y + 47), alt);
|
||||
Check("드래그 이동 +33,+47", textBox.X == origX + 33 && textBox.Y == origY + 47,
|
||||
$"실제 ({textBox.X},{textBox.Y}) 기대 ({origX + 33},{origY + 47})");
|
||||
|
||||
// 4) Undo → 위치 원복(VM 재생성되므로 재조회)
|
||||
designer.Undo.Undo();
|
||||
var textBoxAfterUndo = Find("TextBox");
|
||||
Check("Undo: 위치 원복", textBoxAfterUndo.X == origX && textBoxAfterUndo.Y == origY,
|
||||
$"실제 ({textBoxAfterUndo.X},{textBoxAfterUndo.Y})");
|
||||
|
||||
// 5) Redo → 이동 재적용
|
||||
designer.Undo.Redo();
|
||||
var textBoxAfterRedo = Find("TextBox");
|
||||
Check("Redo: 이동 재적용", textBoxAfterRedo.X == origX + 33 && textBoxAfterRedo.Y == origY + 47);
|
||||
|
||||
// 6) 리사이즈 — SE 핸들(4번) 드래그
|
||||
var target = Find("TextBox");
|
||||
designer.Selection.SetSingle(target);
|
||||
var bbox = designer.SelectionWorldBounds()!.Value;
|
||||
var seHandle = new Point(bbox.Right, bbox.Bottom);
|
||||
var origW = target.Width;
|
||||
var origH = target.Height;
|
||||
designer.Interaction.PointerDown(seHandle, none);
|
||||
designer.Interaction.PointerMove(new Point(seHandle.X + 40, seHandle.Y + 18), alt);
|
||||
designer.Interaction.PointerUp(new Point(seHandle.X + 40, seHandle.Y + 18), alt);
|
||||
Check("리사이즈 +40,+18", target.Width == origW + 40 && target.Height == origH + 18,
|
||||
$"실제 ({target.Width},{target.Height}) 기대 ({origW + 40},{origH + 18})");
|
||||
|
||||
// 7) 마퀴 다중선택 — 전체를 덮는 사각형
|
||||
designer.Selection.Clear();
|
||||
designer.Interaction.PointerDown(new Point(1, 1), none);
|
||||
designer.Interaction.PointerMove(new Point(700, 700), none);
|
||||
designer.Interaction.PointerUp(new Point(700, 700), none);
|
||||
Check("마퀴: 3개 선택", designer.Selection.Items.Count == 3,
|
||||
$"실제 {designer.Selection.Items.Count}");
|
||||
|
||||
// 8) Ctrl+클릭 토글 해제
|
||||
var label = Find("Label");
|
||||
var labelPoint = new Point(label.X + 3, label.Y + 3);
|
||||
designer.Interaction.PointerDown(labelPoint, ctrl);
|
||||
designer.Interaction.PointerUp(labelPoint, ctrl);
|
||||
Check("Ctrl+클릭: 토글 해제 → 2개", designer.Selection.Items.Count == 2);
|
||||
|
||||
// 9) 복사/붙여넣기
|
||||
designer.Selection.SetSingle(Find("CheckBox"));
|
||||
designer.CopySelection();
|
||||
designer.Paste();
|
||||
Check("붙여넣기: 4개 + 새 Id", designer.Pages[0].Controls.Count == 4
|
||||
&& designer.Pages[0].Controls.Count(c => c.Type == "CheckBox") == 2
|
||||
&& designer.Pages[0].Controls.Where(c => c.Type == "CheckBox").Select(c => c.Id).Distinct().Count() == 2);
|
||||
|
||||
// 10) 삭제
|
||||
designer.DeleteSelection();
|
||||
Check("삭제: 3개", designer.Pages[0].Controls.Count == 3);
|
||||
|
||||
// 11) z-order — TextBox 맨 뒤로 (모델 마지막 = VM 첫번째)
|
||||
designer.Selection.SetSingle(Find("TextBox"));
|
||||
designer.ReorderSelection(toFront: false);
|
||||
Check("맨 뒤로: 모델 끝/VM 처음",
|
||||
designer.Document.Pages[0].Controls[^1].Type == "TextBox"
|
||||
&& designer.Pages[0].Controls[0].Type == "TextBox");
|
||||
|
||||
// 12) 넛지
|
||||
var nudgeTarget = Find("Label");
|
||||
designer.Selection.SetSingle(nudgeTarget);
|
||||
var beforeNudgeX = nudgeTarget.X;
|
||||
designer.NudgeSelection(1, 0);
|
||||
designer.NudgeSelection(1, 0);
|
||||
Check("넛지 +2", nudgeTarget.X == beforeNudgeX + 2);
|
||||
|
||||
// 13) 저장 → 재로드 → 재직렬화 동일(왕복 안정)
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), "sheetme_editsmoke.xml");
|
||||
business.SaveXmlFile(designer.Document, tempPath);
|
||||
var reloaded = business.OpenXmlFile(tempPath);
|
||||
var xmlA = serializer.Write(designer.Document);
|
||||
var xmlB = serializer.Write(reloaded);
|
||||
Check("저장/재로드 왕복 동일", xmlA == xmlB);
|
||||
|
||||
// 14) 신규 컨트롤 XML 에 레거시 필수 요소 포함
|
||||
Check("XML: AQN/Location/Name 포함",
|
||||
xmlA.Contains("M.EMR.UserControl.TextBox,") && xmlA.Contains("<Property name=\"Location\">")
|
||||
&& xmlA.Contains("<Property name=\"Name\">"));
|
||||
|
||||
// 15) 인스펙터 — 선택 시 행 구성 + Text 커밋
|
||||
var inspectorTarget = Find("Label");
|
||||
designer.Selection.SetSingle(inspectorTarget);
|
||||
Check("인스펙터: 행 생성", designer.Inspector.Rows.Count > 5,
|
||||
$"행 {designer.Inspector.Rows.Count}");
|
||||
var textRow = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.MultilineTextRowViewModel>()
|
||||
.FirstOrDefault(r => r.Label == "텍스트");
|
||||
Check("인스펙터: 텍스트 행 존재", textRow is not null);
|
||||
if (textRow is not null)
|
||||
{
|
||||
textRow.ValueText = "수정된 라벨";
|
||||
Check("인스펙터: Text 커밋 → 모델 반영",
|
||||
inspectorTarget.Model.Props.GetText("Text") == "수정된 라벨"
|
||||
&& inspectorTarget.Text == "수정된 라벨");
|
||||
Check("인스펙터: 커밋이 Undo 스텝", designer.Undo.CanUndo);
|
||||
}
|
||||
|
||||
// 16) 인스펙터 — 이름 변경
|
||||
var nameRow = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.TextRowViewModel>()
|
||||
.FirstOrDefault(r => r.Label == "이름");
|
||||
if (nameRow is not null)
|
||||
{
|
||||
nameRow.ValueText = "lblTitle";
|
||||
Check("인스펙터: 개명 반영", inspectorTarget.Id == "lblTitle");
|
||||
}
|
||||
|
||||
// 17) 페이지 추가/삭제 + 용지 크기
|
||||
designer.AddPage();
|
||||
Check("페이지 추가: 2페이지", designer.Pages.Count == 2
|
||||
&& designer.Document.Pages.Count == 2);
|
||||
designer.SelectedPage = designer.Pages[1];
|
||||
designer.ApplyPaperSize(794, 1123);
|
||||
Check("용지 크기 변경(A4)", designer.Pages[1].WidthDip == 794 && designer.Pages[1].HeightDip == 1123);
|
||||
designer.RemoveSelectedPageForSmoke();
|
||||
Check("페이지 삭제: 1페이지", designer.Pages.Count == 1);
|
||||
|
||||
// 18) 컨테이너 드롭 — Panel 배치 후 내부에 TextBox 드롭
|
||||
designer.AddControlAt("Panel", new Point(400, 400));
|
||||
var panel = designer.Pages[0].Controls.OfType<PanelViewModel>().First();
|
||||
designer.AddControlAt("TextBox", new Point(panel.X + 30, panel.Y + 30));
|
||||
Check("컨테이너 드롭: 자식 추가", panel.Model.Children.Count == 1
|
||||
&& panel.Children.Count == 1
|
||||
&& panel.Model.Children[0].Type == "TextBox");
|
||||
|
||||
// 19) 컨테이너 자식 직접 편집 — 히트테스트/이동/삭제
|
||||
var panelVm = designer.Pages[0].Controls.OfType<PanelViewModel>().First();
|
||||
var child = panelVm.Children[0];
|
||||
var childWorld = designer.WorldBoundsOf(child);
|
||||
var childHit = designer.HitTestControl(new Point(childWorld.X + childWorld.Width / 2, childWorld.Y + childWorld.Height / 2));
|
||||
Check("자식 히트테스트", childHit == child, $"실제 {childHit?.Id}");
|
||||
|
||||
designer.Selection.SetSingle(child);
|
||||
var childOrigX = child.X;
|
||||
var center = new Point(childWorld.X + childWorld.Width / 2, childWorld.Y + childWorld.Height / 2);
|
||||
designer.Interaction.PointerDown(center, none);
|
||||
designer.Interaction.PointerMove(new Point(center.X + 15, center.Y + 10), alt);
|
||||
designer.Interaction.PointerUp(new Point(center.X + 15, center.Y + 10), alt);
|
||||
Check("자식 이동(+15,+10 상대좌표)", child.X == childOrigX + 15,
|
||||
$"실제 X={child.X} 기대 {childOrigX + 15}");
|
||||
|
||||
designer.Selection.SetSingle(child);
|
||||
designer.DeleteSelection();
|
||||
Check("자식 삭제", panelVm.Children.Count == 0 && panelVm.Model.Children.Count == 0);
|
||||
|
||||
// 20) 그룹/해제 — 그룹 단위 클릭 선택
|
||||
designer.Selection.Set(new[] { Find("TextBox"), Find("Label") });
|
||||
designer.GroupSelection();
|
||||
designer.Selection.Clear();
|
||||
var member = Find("TextBox");
|
||||
var memberPoint = new Point(member.X + member.Width / 2, member.Y + member.Height / 2);
|
||||
designer.Interaction.PointerDown(memberPoint, none);
|
||||
designer.Interaction.PointerUp(memberPoint, none);
|
||||
Check("그룹 클릭 → 그룹 전체 선택", designer.Selection.Items.Count == 2,
|
||||
$"실제 {designer.Selection.Items.Count}");
|
||||
designer.UngroupSelection();
|
||||
designer.Selection.Clear();
|
||||
designer.Interaction.PointerDown(memberPoint, none);
|
||||
designer.Interaction.PointerUp(memberPoint, none);
|
||||
Check("그룹 해제 후 단일 선택", designer.Selection.Items.Count == 1);
|
||||
|
||||
// 20-1) 인라인 텍스트 편집 — 더블클릭 요청 이벤트 + 커밋
|
||||
var inlineTarget = Find("Label");
|
||||
ControlViewModel? inlineRequested = null;
|
||||
designer.InlineEditRequested += vm => inlineRequested = vm;
|
||||
var inlineWorld = designer.WorldBoundsOf(inlineTarget);
|
||||
var inlineCenter = new Point(inlineWorld.X + inlineWorld.Width / 2, inlineWorld.Y + inlineWorld.Height / 2);
|
||||
designer.Interaction.PointerDown(inlineCenter, new PointerContext(false, false, false, 2));
|
||||
designer.Interaction.PointerUp(inlineCenter, new PointerContext(false, false, false, 2));
|
||||
Check("더블클릭 → 인라인 편집 요청", inlineRequested == inlineTarget,
|
||||
$"실제 {inlineRequested?.Id ?? "null"}");
|
||||
designer.CommitInlineText(inlineTarget, "인라인 수정됨");
|
||||
Check("인라인 커밋 → 모델/Undo 반영",
|
||||
inlineTarget.Text == "인라인 수정됨"
|
||||
&& inlineTarget.Model.Props.GetText("Text") == "인라인 수정됨"
|
||||
&& designer.Undo.CanUndo);
|
||||
|
||||
// 20-2) 폰트 일괄 변경 — 집계 + 적용
|
||||
var usage = designer.CollectFontUsage();
|
||||
Check("폰트 집계: 조합 존재", usage.Count >= 2, $"실제 {usage.Count}");
|
||||
var applied = designer.ApplyFontBulk(usage, new Core.Serialization.LegacyFont
|
||||
{
|
||||
Family = "돋움",
|
||||
SizePt = 12,
|
||||
Bold = true,
|
||||
});
|
||||
Check("폰트 일괄 적용: 대상 수", applied >= 2, $"실제 {applied}");
|
||||
var fontTarget = Find("Label");
|
||||
Check("폰트 일괄 적용: 모델 반영",
|
||||
fontTarget.Model.Props.GetText("Font") == "돋움, 12pt, style=Bold"
|
||||
&& fontTarget.EffectiveFont.Family == "돋움" && fontTarget.EffectiveFont.Bold);
|
||||
designer.Undo.Undo();
|
||||
Check("폰트 일괄 적용: Undo 1스텝 원복",
|
||||
Find("Label").Model.Props.GetText("Font") != "돋움, 12pt, style=Bold");
|
||||
|
||||
// 20-3) 문서 간 복사/붙여넣기 — A 서식에서 복사 → B 서식에 붙여넣기(클립보드 공유)
|
||||
var designerB = new DesignerViewModel(business.CreateNew());
|
||||
designer.Selection.SetSingle(Find("TextBox"));
|
||||
designer.CopySelection();
|
||||
var beforePasteB = designerB.Pages[0].Controls.Count;
|
||||
designerB.Paste();
|
||||
Check("문서 간 붙여넣기: B 문서에 추가", designerB.Pages[0].Controls.Count == beforePasteB + 1
|
||||
&& designerB.Pages[0].Controls.Any(c => c.Type == "TextBox"),
|
||||
$"실제 {designerB.Pages[0].Controls.Count}");
|
||||
Check("문서 간 붙여넣기: A 문서 불변", designer.Pages[0].Controls.Count(c => c.Type == "TextBox") == 1);
|
||||
|
||||
// 20-4) 배치 명령 — 같은 크기/간격 균등/페이지 가운데/잘라내기
|
||||
designer.AddControlAt("Label", new Point(100, 500));
|
||||
designer.AddControlAt("Label", new Point(300, 520));
|
||||
designer.AddControlAt("Label", new Point(560, 540));
|
||||
var arrangeTargets = designer.Pages[0].Controls.Where(c => c.Y >= 480).ToList();
|
||||
designer.Selection.Set(arrangeTargets, arrangeTargets[0]);
|
||||
|
||||
designer.SizeToControl("Both");
|
||||
Check("같은 크기로: 기준에 통일",
|
||||
arrangeTargets.All(c => c.Width == arrangeTargets[0].Width && c.Height == arrangeTargets[0].Height));
|
||||
|
||||
designer.AdjustSpacing("HorizEqual");
|
||||
var sorted = arrangeTargets.OrderBy(c => c.X).ToList();
|
||||
var gap1 = sorted[1].X - (sorted[0].X + sorted[0].Width);
|
||||
var gap2 = sorted[2].X - (sorted[1].X + sorted[1].Width);
|
||||
Check("가로 간격 균등", Math.Abs(gap1 - gap2) <= 1, $"gap1={gap1} gap2={gap2}");
|
||||
|
||||
designer.AdjustSpacing("HorizConcat");
|
||||
sorted = arrangeTargets.OrderBy(c => c.X).ToList();
|
||||
Check("가로 붙이기: 간격 0", sorted[1].X == sorted[0].X + sorted[0].Width
|
||||
&& sorted[2].X == sorted[1].X + sorted[1].Width);
|
||||
|
||||
designer.CenterInPage("H");
|
||||
var bboxAfterCenter = designer.SelectionWorldBounds()!.Value;
|
||||
var pageW = designer.Pages[0].WidthDip;
|
||||
Check("페이지 가로 가운데", Math.Abs((bboxAfterCenter.X + bboxAfterCenter.Width / 2) - pageW / 2) <= 1,
|
||||
$"중심 {bboxAfterCenter.X + bboxAfterCenter.Width / 2} 기대 {pageW / 2}");
|
||||
|
||||
var beforeCut = designer.Pages[0].Controls.Count;
|
||||
designer.Selection.Set(new[] { arrangeTargets[0] }, arrangeTargets[0]);
|
||||
designer.CutSelection();
|
||||
Check("잘라내기: 삭제됨", designer.Pages[0].Controls.Count == beforeCut - 1);
|
||||
designer.Paste();
|
||||
Check("잘라내기 → 붙여넣기 복원", designer.Pages[0].Controls.Count == beforeCut);
|
||||
|
||||
// 남은 배치 테스트 컨트롤 정리
|
||||
designer.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 480).ToList());
|
||||
designer.DeleteSelection();
|
||||
|
||||
// 20-5) 탭순서 편집 — 모드 진입 → 역순 클릭 → 완료 적용(레거시 규약 0,100,200…)
|
||||
designer.AddControlAt("TextBox", new Point(100, 620));
|
||||
designer.AddControlAt("TextBox", new Point(300, 620));
|
||||
designer.AddControlAt("TextBox", new Point(500, 620));
|
||||
var tabTargets = designer.Pages[0].Controls
|
||||
.Where(c => c.Type == "TextBox" && c.Y >= 600)
|
||||
.OrderBy(c => c.X)
|
||||
.ToList();
|
||||
|
||||
designer.ToggleTabOrderMode(); // 시작
|
||||
Check("탭순서: 모드 진입 + 배지 생성", designer.IsTabOrderMode
|
||||
&& designer.Overlay.TabBadges.Count >= 3,
|
||||
$"배지 {designer.Overlay.TabBadges.Count}");
|
||||
|
||||
// 오른쪽→왼쪽 역순으로 클릭(입력 순서 지정)
|
||||
for (var i = tabTargets.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var world = designer.WorldBoundsOf(tabTargets[i]);
|
||||
designer.Interaction.PointerDown(new Point(world.X + world.Width / 2, world.Y + world.Height / 2), none);
|
||||
designer.Interaction.PointerUp(new Point(world.X + world.Width / 2, world.Y + world.Height / 2), none);
|
||||
}
|
||||
Check("탭순서: 배지 순번 표시", designer.Overlay.TabBadges.Count(b => b.IsAssigned) == 3);
|
||||
|
||||
designer.ToggleTabOrderMode(); // 완료(적용)
|
||||
Check("탭순서: 적용 — 클릭 순서 0/100/200", !designer.IsTabOrderMode
|
||||
&& tabTargets[2].Model.Props.GetText("TabIndex") == "0"
|
||||
&& tabTargets[1].Model.Props.GetText("TabIndex") == "100"
|
||||
&& tabTargets[0].Model.Props.GetText("TabIndex") == "200",
|
||||
$"실제 {tabTargets[2].Model.Props.GetText("TabIndex")}/{tabTargets[1].Model.Props.GetText("TabIndex")}/{tabTargets[0].Model.Props.GetText("TabIndex")}");
|
||||
Check("탭순서: 미클릭 컨트롤 뒤 순번(999999+)",
|
||||
Find("Label").Model.Props.GetText("TabIndex")?.StartsWith("1000") == true);
|
||||
designer.Undo.Undo();
|
||||
Check("탭순서: Undo 원복",
|
||||
designer.Pages[0].Controls.First(c => c.Type == "TextBox" && c.Y >= 600).Model.Props.GetText("TabIndex") is null or "0");
|
||||
|
||||
// 정리
|
||||
designer.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 600).ToList());
|
||||
designer.DeleteSelection();
|
||||
|
||||
// 20-6) 전체 속성 그리드(고급) — 토글 → raw 편집 → 속성 추가
|
||||
var advTarget = Find("TextBox");
|
||||
designer.Selection.SetSingle(advTarget);
|
||||
var toggleRow = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.ToggleAdvancedRowViewModel>().FirstOrDefault();
|
||||
Check("고급: 토글 행 존재", toggleRow is not null);
|
||||
toggleRow!.ToggleCommand!.Execute(null, EventArgs.Empty);
|
||||
var advRows = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.TextRowViewModel>()
|
||||
.Where(r => r.Label is "LocationOnBase" or "TabIndex" or "DisplaySequence")
|
||||
.ToList();
|
||||
Check("고급: raw 속성 행 노출", designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.SectionRowViewModel>()
|
||||
.Any(r => r.Label.StartsWith("전체 속성")));
|
||||
|
||||
var addRow = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.AddPropertyRowViewModel>().FirstOrDefault();
|
||||
Check("고급: 속성 추가 행 존재", addRow is not null);
|
||||
addRow!.KeyText = "EnterTabYon";
|
||||
addRow.AddCommand!.Execute(null, EventArgs.Empty);
|
||||
Check("고급: 속성 추가됨", advTarget.Model.Props.Contains("EnterTabYon"));
|
||||
var enterTabRow = designer.Inspector.Rows
|
||||
.OfType<ViewModels.Inspector.TextRowViewModel>()
|
||||
.FirstOrDefault(r => r.Label == "EnterTabYon");
|
||||
Check("고급: 추가 속성 행 노출", enterTabRow is not null);
|
||||
enterTabRow!.ValueText = "True";
|
||||
Check("고급: raw 값 커밋", advTarget.Model.Props.GetText("EnterTabYon") == "True");
|
||||
|
||||
// 21) JSON 왕복(부 포맷) — 편집 결과 유지
|
||||
var jsonSerializer = new Core.Serialization.FormJsonSerializer();
|
||||
var json = jsonSerializer.Write(designer.Document);
|
||||
var fromJson = jsonSerializer.Read(json);
|
||||
Check("JSON 왕복 — XML 동일", serializer.Write(fromJson) == serializer.Write(designer.Document));
|
||||
|
||||
// 22) 연속 Undo 로 빈 문서까지
|
||||
var guard = 0;
|
||||
while (designer.Undo.CanUndo && guard++ < 80)
|
||||
{
|
||||
designer.Undo.Undo();
|
||||
}
|
||||
Check("전체 Undo → 빈 문서", designer.Document.Pages[0].Controls.Count == 0
|
||||
&& designer.Document.Pages.Count == 1,
|
||||
$"실제 컨트롤 {designer.Document.Pages[0].Controls.Count} 페이지 {designer.Document.Pages.Count}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
lines.Add("EXCEPTION " + ex);
|
||||
}
|
||||
|
||||
lines.Add($"결과: 실패 {failed}건");
|
||||
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SheetMe.Data.Config;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// appsettings → DataConfig 바인딩 로더.
|
||||
/// 우선순위: 환경변수 > appsettings.Development.json > appsettings.json.
|
||||
/// 커밋되는 appsettings.json 은 <c>__HOST__</c> 류 플레이스홀더만 담으며, 실접속 정보는
|
||||
/// appsettings.Development.json(개발, Debug 빌드에서만 산출물 복사) 또는 환경변수로 주입한다.
|
||||
/// </summary>
|
||||
public static class ConfigLoader
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>설정 로드 — 설정이 없으면 기본값(File 모드)</summary>
|
||||
public static DataConfig Load()
|
||||
{
|
||||
var config = new DataConfig();
|
||||
var basePath = AppContext.BaseDirectory;
|
||||
|
||||
var root = new ConfigurationBuilder()
|
||||
.SetBasePath(basePath)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Development.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var connectionString = root.GetConnectionString("His") ?? string.Empty;
|
||||
config.ConnectionString = IsPlaceholder(connectionString) ? string.Empty : connectionString;
|
||||
config.Provider = root["His:Provider"] ?? "Oracle";
|
||||
config.SaveMode = root["FormStore:SaveMode"] ?? "File";
|
||||
config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms";
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 커밋본의 미치환 플레이스홀더인지 — 이 경우 '미설정'으로 간주해 DB 기능을 끈다.
|
||||
/// 플레이스홀더를 그대로 접속에 쓰면 무의미한 연결 실패 예외가 사용자에게 노출된다.
|
||||
/// </summary>
|
||||
private static bool IsPlaceholder(string connectionString) =>
|
||||
connectionString.Length == 0 || connectionString.Contains("__", StringComparison.Ordinal);
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>파일 대화상자 래퍼 — ViewModel 에서 View 기술 의존을 격리.</summary>
|
||||
public sealed class DialogService
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>열기 대화상자 — 취소 시 null</summary>
|
||||
public string? ShowOpenFile(string filter, string title)
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Filter = filter,
|
||||
Title = title,
|
||||
};
|
||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
/// <summary>저장 대화상자 — 취소 시 null</summary>
|
||||
public string? ShowSaveFile(string filter, string title, string defaultFileName)
|
||||
{
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Filter = filter,
|
||||
Title = title,
|
||||
FileName = defaultFileName,
|
||||
};
|
||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 모델 ↔ ViewModel 매핑 팩토리.
|
||||
/// z-order 규약: 모델(=XML Object 순서)은 index 0 이 최상위(WinForms Controls 규약),
|
||||
/// WPF Canvas 는 나중에 그린 것이 위 — 따라서 VM 컬렉션은 모델의 역순(그리기 순서)이다.
|
||||
/// </summary>
|
||||
public static class DocumentMapper
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>페이지 모델 → 페이지 VM (컨트롤 트리 포함)</summary>
|
||||
public static PageViewModel CreatePage(FormPage page, int index)
|
||||
{
|
||||
var pageViewModel = new PageViewModel(page, index);
|
||||
|
||||
// 루트 폰트/글자색이 상속의 기점 (실샘플: 루트 Font "굴림, 11.25pt")
|
||||
var rootFontText = page.Root.Props.GetText("Font");
|
||||
var rootFont = rootFontText is not null ? LegacyFormat.ParseFont(rootFontText) : new LegacyFont();
|
||||
var rootForeground = Brushes.Black;
|
||||
|
||||
foreach (var model in PaintOrder(page.Controls))
|
||||
{
|
||||
pageViewModel.Controls.Add(CreateControl(model, rootFont, rootForeground));
|
||||
}
|
||||
return pageViewModel;
|
||||
}
|
||||
|
||||
/// <summary>컨트롤 모델 → 타입별 VM (자식·시각 컨텍스트 포함)</summary>
|
||||
public static ControlViewModel CreateControl(ControlElement model, LegacyFont parentFont, Brush parentForeground)
|
||||
{
|
||||
ControlViewModel viewModel = model.Type switch
|
||||
{
|
||||
"Label" => new LabelViewModel(model),
|
||||
"TextBox" => new TextBoxViewModel(model),
|
||||
"MaskedTextBox" => new MaskedTextBoxViewModel(model),
|
||||
"CheckBox" => new CheckBoxViewModel(model),
|
||||
"RadioButton" => new RadioButtonViewModel(model),
|
||||
"ComboBox" => new ComboBoxViewModel(model),
|
||||
"ListBox" => new ListBoxViewModel(model),
|
||||
"CheckList" => new CheckListViewModel(model),
|
||||
"DateTimePicker" => new DateTimePickerViewModel(model),
|
||||
"Panel" => new PanelViewModel(model),
|
||||
"GroupBox" => new GroupBoxViewModel(model),
|
||||
"Line" => new LineViewModel(model),
|
||||
"PictureBox" => new PictureBoxViewModel(model),
|
||||
"CalcBox" => new CalcBoxViewModel(model),
|
||||
"Button" => new ButtonViewModel(model),
|
||||
"DataTable" => new DataTableViewModel(model),
|
||||
"Spread" => new SpreadViewModel(model),
|
||||
_ => new PlaceholderViewModel(model),
|
||||
};
|
||||
|
||||
viewModel.ResolveVisualContext(parentFont, parentForeground);
|
||||
|
||||
if (viewModel is ContainerViewModel container)
|
||||
{
|
||||
foreach (var child in PaintOrder(model.Children))
|
||||
{
|
||||
var childViewModel = CreateControl(child, viewModel.EffectiveFont, viewModel.Foreground);
|
||||
childViewModel.Parent = container;
|
||||
container.Children.Add(childViewModel);
|
||||
}
|
||||
}
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
/// <summary>모델 순서(0=최상위) → 그리기 순서(마지막=최상위)로 역전</summary>
|
||||
private static IEnumerable<ControlElement> PaintOrder(List<ControlElement> models)
|
||||
=> Enumerable.Reverse(models);
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>컨트롤 Id 생성 — 문서 전체에서 유일한 "TextBox1" 식 이름 부여(레거시 관행).</summary>
|
||||
public static class IdGenerator
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>문서의 사용 중 Id 집합 수집</summary>
|
||||
public static HashSet<string> CollectUsed(FormDocument document)
|
||||
{
|
||||
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var page in document.Pages)
|
||||
{
|
||||
Collect(page.Root, used);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
/// <summary>타입 기반 유일 Id 생성 — 발급한 Id 는 used 집합에 추가된다(연속 발급 안전)</summary>
|
||||
public static string NextId(HashSet<string> used, string type)
|
||||
{
|
||||
for (var i = 1; ; i++)
|
||||
{
|
||||
var candidate = type + i;
|
||||
if (used.Add(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>단건 발급 편의 — 문서 스캔 후 1개 생성</summary>
|
||||
public static string NextId(FormDocument document, string type)
|
||||
=> NextId(CollectUsed(document), type);
|
||||
|
||||
private static void Collect(ControlElement element, HashSet<string> used)
|
||||
{
|
||||
if (element.Id.Length > 0)
|
||||
{
|
||||
used.Add(element.Id);
|
||||
}
|
||||
foreach (var child in element.Children)
|
||||
{
|
||||
Collect(child, used);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>포인터 컨텍스트 — 수정키/클릭 수</summary>
|
||||
public readonly record struct PointerContext(bool Ctrl, bool Shift, bool Alt, int ClickCount);
|
||||
|
||||
/// <summary>커서 종류 — 뷰(Behavior)가 WPF Cursor 로 매핑</summary>
|
||||
public enum CursorKind
|
||||
{
|
||||
Arrow,
|
||||
SizeAll,
|
||||
SizeNWSE,
|
||||
SizeNESW,
|
||||
SizeNS,
|
||||
SizeWE,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 마우스 상호작용 상태머신 — 월드 좌표만 다루는 순수 로직(뷰 비의존).
|
||||
/// 상태: None → PendingMove(임계 대기) → Move / Resize / Marquee.
|
||||
/// 드래그 = Undo 1스텝(첫 실이동 시 스냅샷), 절대좌표 재계산(드리프트 방지).
|
||||
/// </summary>
|
||||
public sealed class InteractionController
|
||||
{
|
||||
#region Member Fields
|
||||
private enum Mode { None, PendingMove, Move, Resize, Marquee }
|
||||
|
||||
private const double DragThreshold = 2;
|
||||
private const double MinSize = 8;
|
||||
|
||||
private readonly DesignerViewModel designer;
|
||||
private Mode mode = Mode.None;
|
||||
private Point downPoint;
|
||||
private ControlViewModel? downControl;
|
||||
private bool toggleOnUp;
|
||||
private bool collapseOnUp;
|
||||
private int resizeHandleIndex = -1;
|
||||
private Rect groupBounds0;
|
||||
private readonly Dictionary<ControlViewModel, Rect> origBounds = new();
|
||||
private bool undoCaptured;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public InteractionController(DesignerViewModel designer)
|
||||
{
|
||||
this.designer = designer;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Pointer
|
||||
/// <summary>마우스 다운(월드 좌표)</summary>
|
||||
public void PointerDown(Point world, PointerContext ctx)
|
||||
{
|
||||
// 탭순서 편집 모드 — 클릭은 순번 지정으로만 동작(드래그/핸들/마퀴 차단)
|
||||
if (designer.IsTabOrderMode)
|
||||
{
|
||||
var tabHit = designer.HitTestControl(world);
|
||||
if (tabHit is not null)
|
||||
{
|
||||
designer.ToggleTabOrderClick(tabHit);
|
||||
}
|
||||
mode = Mode.None;
|
||||
return;
|
||||
}
|
||||
|
||||
toggleOnUp = false;
|
||||
collapseOnUp = false;
|
||||
undoCaptured = false;
|
||||
downPoint = world;
|
||||
|
||||
// 1) 핸들 히트 — 선택 bbox 의 8핸들
|
||||
var handleIndex = HandleAt(world);
|
||||
if (handleIndex >= 0)
|
||||
{
|
||||
mode = Mode.Resize;
|
||||
resizeHandleIndex = handleIndex;
|
||||
CaptureOrigBounds();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 컨트롤 히트 — 그룹 멤버면 그룹 전체 단위로 선택
|
||||
var hit = designer.HitTestControl(world);
|
||||
if (hit is not null)
|
||||
{
|
||||
// 더블클릭 → 인라인 텍스트 편집(드래그 시작 안 함)
|
||||
if (ctx.ClickCount >= 2 && DesignerViewModel.IsTextEditable(hit))
|
||||
{
|
||||
mode = Mode.None;
|
||||
designer.RequestInlineEdit(hit);
|
||||
return;
|
||||
}
|
||||
|
||||
downControl = hit;
|
||||
if (ctx.Ctrl || ctx.Shift)
|
||||
{
|
||||
toggleOnUp = true; // 드래그 없이 업이면 토글
|
||||
}
|
||||
else if (!hit.IsSelected)
|
||||
{
|
||||
designer.Selection.Set(designer.GroupMatesOf(hit), hit);
|
||||
}
|
||||
else if (designer.Selection.Items.Count > designer.GroupMatesOf(hit).Count)
|
||||
{
|
||||
collapseOnUp = true; // 그룹보다 넓은 다중선택에서 클릭만 하면 그룹/단일로 축소
|
||||
}
|
||||
mode = Mode.PendingMove;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 빈 곳 — 마퀴
|
||||
if (!ctx.Ctrl && !ctx.Shift)
|
||||
{
|
||||
designer.Selection.Clear();
|
||||
}
|
||||
mode = Mode.Marquee;
|
||||
designer.Overlay.UpdateMarquee(new Rect(world, world));
|
||||
}
|
||||
|
||||
/// <summary>마우스 이동(월드 좌표)</summary>
|
||||
public void PointerMove(Point world, PointerContext ctx)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case Mode.PendingMove:
|
||||
if (Distance(world, downPoint) > DragThreshold && downControl is not null)
|
||||
{
|
||||
if (downControl.Model.Locked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
mode = Mode.Move;
|
||||
toggleOnUp = false;
|
||||
collapseOnUp = false;
|
||||
BeginMove();
|
||||
DoMove(world, ctx); // 전환 프레임부터 즉시 추종
|
||||
}
|
||||
break;
|
||||
|
||||
case Mode.Move:
|
||||
DoMove(world, ctx);
|
||||
break;
|
||||
|
||||
case Mode.Resize:
|
||||
DoResize(world, ctx);
|
||||
break;
|
||||
|
||||
case Mode.Marquee:
|
||||
designer.Overlay.UpdateMarquee(RectFrom(downPoint, world));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>마우스 업(월드 좌표)</summary>
|
||||
public void PointerUp(Point world, PointerContext ctx)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case Mode.PendingMove:
|
||||
if (toggleOnUp && downControl is not null)
|
||||
{
|
||||
// 그룹 단위 토글
|
||||
var mates = designer.GroupMatesOf(downControl);
|
||||
if (downControl.IsSelected)
|
||||
{
|
||||
designer.Selection.Set(designer.Selection.Items.Except(mates).ToList());
|
||||
}
|
||||
else
|
||||
{
|
||||
designer.Selection.Set(designer.Selection.Items.Union(mates).ToList(), downControl);
|
||||
}
|
||||
}
|
||||
else if (collapseOnUp && downControl is not null)
|
||||
{
|
||||
designer.Selection.Set(designer.GroupMatesOf(downControl), downControl);
|
||||
}
|
||||
break;
|
||||
|
||||
case Mode.Move:
|
||||
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
|
||||
designer.Overlay.SetGuides(null, null);
|
||||
break;
|
||||
|
||||
case Mode.Resize:
|
||||
designer.Overlay.SetGuides(null, null);
|
||||
break;
|
||||
|
||||
case Mode.Marquee:
|
||||
var rect = RectFrom(downPoint, world);
|
||||
designer.Overlay.UpdateMarquee(null);
|
||||
var hits = designer.ControlsIntersecting(rect)
|
||||
.SelectMany(designer.GroupMatesOf)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (ctx.Ctrl || ctx.Shift)
|
||||
{
|
||||
var merged = designer.Selection.Items.Union(hits).ToList();
|
||||
designer.Selection.Set(merged);
|
||||
}
|
||||
else if (hits.Count > 0)
|
||||
{
|
||||
designer.Selection.Set(hits);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
mode = Mode.None;
|
||||
downControl = null;
|
||||
origBounds.Clear();
|
||||
designer.RefreshOverlay();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 진행 중 드래그만 취소(원위치 복원) — 캡처 상실 등 비정상 종료용.
|
||||
/// 유휴 상태에서는 아무것도 하지 않는다(정상 마우스 업 후 캡처 해제가 선택을 지우면 안 됨).
|
||||
/// </summary>
|
||||
public void CancelDrag()
|
||||
{
|
||||
if (mode is Mode.Move or Mode.Resize)
|
||||
{
|
||||
foreach (var (vm, rect) in origBounds)
|
||||
{
|
||||
var offset = designer.ParentWorldOffset(vm);
|
||||
vm.X = rect.X - offset.X;
|
||||
vm.Y = rect.Y - offset.Y;
|
||||
vm.Width = rect.Width;
|
||||
vm.Height = rect.Height;
|
||||
}
|
||||
designer.Overlay.SetGuides(null, null);
|
||||
mode = Mode.None;
|
||||
origBounds.Clear();
|
||||
designer.RefreshOverlay();
|
||||
}
|
||||
else if (mode is Mode.Marquee or Mode.PendingMove)
|
||||
{
|
||||
designer.Overlay.UpdateMarquee(null);
|
||||
mode = Mode.None;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Esc — 탭순서 모드 취소 / 진행 중 드래그 취소 / 유휴 상태면 선택 해제</summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (designer.IsTabOrderMode)
|
||||
{
|
||||
designer.CancelTabOrderMode();
|
||||
return;
|
||||
}
|
||||
if (mode == Mode.None)
|
||||
{
|
||||
designer.Selection.Clear();
|
||||
return;
|
||||
}
|
||||
CancelDrag();
|
||||
}
|
||||
|
||||
/// <summary>커서 판정(호버 피드백)</summary>
|
||||
public CursorKind CursorAt(Point world)
|
||||
{
|
||||
var handle = HandleAt(world);
|
||||
if (handle >= 0)
|
||||
{
|
||||
return handle switch
|
||||
{
|
||||
0 or 4 => CursorKind.SizeNWSE,
|
||||
2 or 6 => CursorKind.SizeNESW,
|
||||
1 or 5 => CursorKind.SizeNS,
|
||||
_ => CursorKind.SizeWE,
|
||||
};
|
||||
}
|
||||
if (mode == Mode.Move)
|
||||
{
|
||||
return CursorKind.SizeAll;
|
||||
}
|
||||
return designer.HitTestControl(world) is not null ? CursorKind.SizeAll : CursorKind.Arrow;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Private
|
||||
private void BeginMove()
|
||||
{
|
||||
CaptureOrigBounds();
|
||||
var page = designer.PageOf(designer.Selection.Primary!) ?? designer.Pages.FirstOrDefault();
|
||||
if (page is not null)
|
||||
{
|
||||
designer.Snap.BeginDrag(page, designer.Selection.Items.ToHashSet());
|
||||
}
|
||||
}
|
||||
|
||||
private void DoMove(Point world, PointerContext ctx)
|
||||
{
|
||||
EnsureUndoSnapshot();
|
||||
|
||||
var dx = world.X - downPoint.X;
|
||||
var dy = world.Y - downPoint.Y;
|
||||
|
||||
var snap = designer.Snap.SnapMove(groupBounds0, dx, dy, ctx.Alt);
|
||||
designer.Overlay.SetGuides(snap.GuideX, snap.GuideY);
|
||||
|
||||
// origBounds 는 월드 좌표 — 로컬 = (원본 월드 + 보정 델타) - 부모 체인 오프셋
|
||||
foreach (var (vm, rect) in origBounds)
|
||||
{
|
||||
var offset = designer.ParentWorldOffset(vm);
|
||||
vm.X = Math.Round(rect.X + snap.Dx - offset.X);
|
||||
vm.Y = Math.Round(rect.Y + snap.Dy - offset.Y);
|
||||
}
|
||||
|
||||
designer.Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
private void DoResize(Point world, PointerContext ctx)
|
||||
{
|
||||
EnsureUndoSnapshot();
|
||||
|
||||
var rect = groupBounds0;
|
||||
var left = rect.Left;
|
||||
var top = rect.Top;
|
||||
var right = rect.Right;
|
||||
var bottom = rect.Bottom;
|
||||
|
||||
// 핸들별 이동 모서리 (0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)
|
||||
if (resizeHandleIndex is 0 or 6 or 7)
|
||||
{
|
||||
left = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
|
||||
}
|
||||
if (resizeHandleIndex is 2 or 3 or 4)
|
||||
{
|
||||
right = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
|
||||
}
|
||||
if (resizeHandleIndex is 0 or 1 or 2)
|
||||
{
|
||||
top = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
|
||||
}
|
||||
if (resizeHandleIndex is 4 or 5 or 6)
|
||||
{
|
||||
bottom = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
|
||||
}
|
||||
|
||||
var newRect = new Rect(
|
||||
Math.Min(left, right - MinSize),
|
||||
Math.Min(top, bottom - MinSize),
|
||||
Math.Max(MinSize, right - left),
|
||||
Math.Max(MinSize, bottom - top));
|
||||
|
||||
// 그룹 비례 스케일 — origBounds 기준 절대 재계산
|
||||
var sx = newRect.Width / Math.Max(1, groupBounds0.Width);
|
||||
var sy = newRect.Height / Math.Max(1, groupBounds0.Height);
|
||||
|
||||
foreach (var (vm, orig) in origBounds)
|
||||
{
|
||||
var offset = designer.ParentWorldOffset(vm);
|
||||
vm.X = Math.Round(newRect.X + (orig.X - groupBounds0.X) * sx - offset.X);
|
||||
vm.Y = Math.Round(newRect.Y + (orig.Y - groupBounds0.Y) * sy - offset.Y);
|
||||
vm.Width = Math.Max(1, Math.Round(orig.Width * sx));
|
||||
vm.Height = Math.Max(1, Math.Round(orig.Height * sy));
|
||||
}
|
||||
|
||||
designer.Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
/// <summary>드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝</summary>
|
||||
private void EnsureUndoSnapshot()
|
||||
{
|
||||
if (!undoCaptured)
|
||||
{
|
||||
designer.Undo.Snapshot();
|
||||
undoCaptured = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>선택 항목의 월드 bbox·개별 원본 bounds 캡처(X 는 페이지 로컬 = 월드 X, Y 는 월드)</summary>
|
||||
private void CaptureOrigBounds()
|
||||
{
|
||||
origBounds.Clear();
|
||||
foreach (var vm in designer.Selection.Items)
|
||||
{
|
||||
origBounds[vm] = designer.WorldBoundsOf(vm);
|
||||
}
|
||||
groupBounds0 = designer.SelectionWorldBounds() ?? Rect.Empty;
|
||||
}
|
||||
|
||||
private int HandleAt(Point world)
|
||||
{
|
||||
var overlay = designer.Overlay;
|
||||
if (!overlay.HasSelection || !overlay.ShowHandles)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
var bbox = new Rect(overlay.SelX, overlay.SelY, overlay.SelW, overlay.SelH);
|
||||
var positions = SelectionOverlayViewModel.HandlePositions(bbox);
|
||||
|
||||
// 반경 내 '최근접' 핸들 선택 — 작은 컨트롤에서 인접 핸들 오인 방지
|
||||
const double hitRadius = 6;
|
||||
var best = -1;
|
||||
var bestDistance = double.MaxValue;
|
||||
for (var i = 0; i < positions.Length; i++)
|
||||
{
|
||||
var dx = Math.Abs(world.X - positions[i].X);
|
||||
var dy = Math.Abs(world.Y - positions[i].Y);
|
||||
if (dx > hitRadius || dy > hitRadius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var distance = dx * dx + dy * dy;
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static double Distance(Point a, Point b)
|
||||
=> Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
|
||||
|
||||
private static Rect RectFrom(Point a, Point b)
|
||||
=> new(Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y));
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 인쇄 서비스 — 페이지당 FixedPage 1:1(DIP)로 조립해 인쇄.
|
||||
/// 캔버스와 동일한 DataTemplate 사전(App 리소스)을 사용하므로 화면=인쇄 렌더가 일치한다.
|
||||
/// 용지 그림자 등 편집 크롬 없이 흰 배경 + 컨트롤만 그린다.
|
||||
/// </summary>
|
||||
public static class PrintService
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary>
|
||||
public static void Print(DesignerViewModel designer, string documentName)
|
||||
{
|
||||
var dialog = new System.Windows.Controls.PrintDialog();
|
||||
if (dialog.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var document = BuildFixedDocument(designer);
|
||||
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
|
||||
}
|
||||
|
||||
/// <summary>페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용)</summary>
|
||||
public static FixedDocument BuildFixedDocument(DesignerViewModel designer)
|
||||
{
|
||||
var document = new FixedDocument();
|
||||
foreach (var page in designer.Pages)
|
||||
{
|
||||
var fixedPage = new FixedPage
|
||||
{
|
||||
Width = page.WidthDip,
|
||||
Height = page.HeightDip,
|
||||
Background = page.PaperBrush,
|
||||
};
|
||||
fixedPage.Children.Add(BuildPageVisual(page));
|
||||
|
||||
var pageContent = new PageContent();
|
||||
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
|
||||
document.Pages.Add(pageContent);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>페이지 컨트롤층 비주얼 — 크롬 없는 Canvas(그리기 순서 = 컬렉션 순서, 미리보기/인쇄 공용)</summary>
|
||||
public static UIElement BuildPageVisual(PageViewModel page)
|
||||
{
|
||||
var canvas = new Canvas
|
||||
{
|
||||
Width = page.WidthDip,
|
||||
Height = page.HeightDip,
|
||||
};
|
||||
TextOptions.SetTextFormattingMode(canvas, TextFormattingMode.Ideal);
|
||||
|
||||
// 종이 위 렌더는 레거시 충실 유지 — 앱 테마의 암시 TextBlock 스타일(다크 밝은 글자) 차단
|
||||
var paperText = new Style(typeof(TextBlock));
|
||||
paperText.Setters.Add(new Setter(TextBlock.ForegroundProperty, Brushes.Black));
|
||||
canvas.Resources.Add(typeof(TextBlock), paperText);
|
||||
|
||||
foreach (var control in page.Controls)
|
||||
{
|
||||
// 숨김 + 데이터소스(MDataTable — 런타임 비가시)는 인쇄/미리보기에서 제외
|
||||
if (control.Model.Hidden || control is DataTableViewModel)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var presenter = new ContentPresenter
|
||||
{
|
||||
Content = control,
|
||||
Width = Math.Max(1, control.Width),
|
||||
Height = Math.Max(1, control.Height),
|
||||
};
|
||||
Canvas.SetLeft(presenter, control.X);
|
||||
Canvas.SetTop(presenter, control.Y);
|
||||
canvas.Children.Add(presenter);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>선택 집합 관리 — 다중선택 + Primary. 변경 시 IsSelected 플래그 동기화 및 Changed 통지.</summary>
|
||||
public sealed class SelectionService
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly List<ControlViewModel> items = new();
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택된 컨트롤 목록</summary>
|
||||
public IReadOnlyList<ControlViewModel> Items => items;
|
||||
|
||||
/// <summary>기준(Primary) 선택</summary>
|
||||
public ControlViewModel? Primary { get; private set; }
|
||||
|
||||
/// <summary>Primary 가 속한 페이지 — 컨트롤 추가 대상 결정용</summary>
|
||||
public PageViewModel? ActivePage { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
/// <summary>선택 변경 통지(경계 이동 포함) — 오버레이 구독</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>선택 '집합' 변경 통지(Set/Toggle/Clear 만) — 인스펙터 재구성 구독(드래그 프레임 제외)</summary>
|
||||
public event Action? SetChanged;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>선택 교체</summary>
|
||||
public void Set(IEnumerable<ControlViewModel> newItems, ControlViewModel? primary = null)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
item.IsSelected = false;
|
||||
}
|
||||
items.Clear();
|
||||
foreach (var item in newItems.Where(i => !i.Model.Locked))
|
||||
{
|
||||
items.Add(item);
|
||||
item.IsSelected = true;
|
||||
}
|
||||
Primary = primary is not null && items.Contains(primary) ? primary : items.FirstOrDefault();
|
||||
Changed?.Invoke();
|
||||
SetChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>단일 선택</summary>
|
||||
public void SetSingle(ControlViewModel item) => Set(new[] { item }, item);
|
||||
|
||||
/// <summary>토글(Ctrl/Shift+클릭)</summary>
|
||||
public void Toggle(ControlViewModel item)
|
||||
{
|
||||
if (items.Contains(item))
|
||||
{
|
||||
items.Remove(item);
|
||||
item.IsSelected = false;
|
||||
if (Primary == item)
|
||||
{
|
||||
Primary = items.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
else if (!item.Model.Locked)
|
||||
{
|
||||
items.Add(item);
|
||||
item.IsSelected = true;
|
||||
Primary = item;
|
||||
}
|
||||
Changed?.Invoke();
|
||||
SetChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>선택 해제</summary>
|
||||
public void Clear() => Set(Array.Empty<ControlViewModel>());
|
||||
|
||||
/// <summary>이동/리사이즈 중 오버레이 갱신 트리거</summary>
|
||||
public void NotifyBoundsChanged() => Changed?.Invoke();
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>스냅 결과 — 보정된 이동량과 표시할 가이드 좌표(월드)</summary>
|
||||
public readonly record struct SnapResult(double Dx, double Dy, double? GuideX, double? GuideY);
|
||||
|
||||
/// <summary>
|
||||
/// 그리드/정렬 스냅 순수 계산.
|
||||
/// 드래그 시작 시 후보(비선택 형제의 좌/중/우·상/중/하 + 용지 경계/중앙)를 1회 사전수집해
|
||||
/// 프레임당 재수집을 피한다.
|
||||
/// </summary>
|
||||
public sealed class SnapEngine
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly List<double> candidatesX = new();
|
||||
private readonly List<double> candidatesY = new();
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>그리드 간격(px)</summary>
|
||||
public double GridSize { get; set; } = 4;
|
||||
|
||||
/// <summary>정렬 스냅 허용 거리(px)</summary>
|
||||
public double Tolerance { get; set; } = 6;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>드래그 시작 — 스냅 후보 사전수집(월드 좌표)</summary>
|
||||
public void BeginDrag(PageViewModel page, IReadOnlySet<ControlViewModel> moving)
|
||||
{
|
||||
candidatesX.Clear();
|
||||
candidatesY.Clear();
|
||||
|
||||
// 용지 경계·중앙
|
||||
candidatesX.Add(0);
|
||||
candidatesX.Add(page.WidthDip / 2);
|
||||
candidatesX.Add(page.WidthDip);
|
||||
candidatesY.Add(page.OffsetY);
|
||||
candidatesY.Add(page.OffsetY + page.HeightDip / 2);
|
||||
candidatesY.Add(page.OffsetY + page.HeightDip);
|
||||
|
||||
foreach (var sibling in page.Controls)
|
||||
{
|
||||
if (moving.Contains(sibling) || sibling.Model.Hidden)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
candidatesX.Add(sibling.X);
|
||||
candidatesX.Add(sibling.X + sibling.Width / 2);
|
||||
candidatesX.Add(sibling.X + sibling.Width);
|
||||
|
||||
var top = page.OffsetY + sibling.Y;
|
||||
candidatesY.Add(top);
|
||||
candidatesY.Add(top + sibling.Height / 2);
|
||||
candidatesY.Add(top + sibling.Height);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이동 스냅 — bbox(월드)에 (dx,dy) 적용 시 정렬 후보와 최근접 정합.
|
||||
/// free=true(Alt)면 스냅 없이 원본 이동량 그대로.
|
||||
/// </summary>
|
||||
public SnapResult SnapMove(Rect bbox, double dx, double dy, bool free)
|
||||
{
|
||||
if (free)
|
||||
{
|
||||
return new SnapResult(dx, dy, null, null);
|
||||
}
|
||||
|
||||
var movedLeft = bbox.X + dx;
|
||||
var movedTop = bbox.Y + dy;
|
||||
|
||||
var (adjustX, guideX) = SnapAxis(new[] { movedLeft, movedLeft + bbox.Width / 2, movedLeft + bbox.Width }, candidatesX);
|
||||
var (adjustY, guideY) = SnapAxis(new[] { movedTop, movedTop + bbox.Height / 2, movedTop + bbox.Height }, candidatesY);
|
||||
|
||||
// 정합 실패 축은 그리드 스냅
|
||||
var resultDx = guideX is not null ? dx + adjustX : Math.Round(movedLeft / GridSize) * GridSize - bbox.X;
|
||||
var resultDy = guideY is not null ? dy + adjustY : Math.Round(movedTop / GridSize) * GridSize - bbox.Y;
|
||||
|
||||
return new SnapResult(resultDx, resultDy, guideX, guideY);
|
||||
}
|
||||
|
||||
/// <summary>리사이즈 스냅 — 움직이는 모서리 좌표만 정합(간이: 그리드 우선)</summary>
|
||||
public double SnapEdge(double value, bool isXAxis, bool free)
|
||||
{
|
||||
if (free)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
var candidates = isXAxis ? candidatesX : candidatesY;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (Math.Abs(candidate - value) <= Tolerance)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return Math.Round(value / GridSize) * GridSize;
|
||||
}
|
||||
|
||||
private (double Adjust, double? Guide) SnapAxis(double[] edges, List<double> candidates)
|
||||
{
|
||||
var best = double.MaxValue;
|
||||
double adjust = 0;
|
||||
double? guide = null;
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var distance = candidate - edge;
|
||||
if (Math.Abs(distance) <= Tolerance && Math.Abs(distance) < Math.Abs(best))
|
||||
{
|
||||
best = distance;
|
||||
adjust = distance;
|
||||
guide = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (adjust, guide);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 라이트/다크 테마 전환 — App.Resources 의 토큰 사전(Tokens.Dark↔Light)을 교체하면
|
||||
/// {DynamicResource B.*} 를 참조하는 모든 스타일이 라이브 리스킨된다([200]SheetMe SwapThemeTokens 이식).
|
||||
/// 선택은 %LocalAppData%\SheetMe\theme.json 에 보존.
|
||||
/// </summary>
|
||||
public static class ThemeManager
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly string SettingsPath =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SheetMe", "theme.json");
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>현재 라이트 테마 여부(기본 다크 — [200]SheetMe 기본값과 동일)</summary>
|
||||
public static bool IsLight { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>시작 시 저장된 테마 적용</summary>
|
||||
public static void LoadSaved()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(SettingsPath) &&
|
||||
JsonDocument.Parse(File.ReadAllText(SettingsPath)).RootElement.TryGetProperty("theme", out var theme) &&
|
||||
theme.GetString() == "light")
|
||||
{
|
||||
Apply(light: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 설정 손상 시 기본(다크) 유지
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>라이트↔다크 토글</summary>
|
||||
public static void Toggle() => Apply(!IsLight);
|
||||
|
||||
/// <summary>테마 적용 — App.Resources 병합 사전에서 토큰 dict 를 찾아 교체</summary>
|
||||
public static void Apply(bool light)
|
||||
{
|
||||
IsLight = light;
|
||||
var dictionaries = Application.Current.Resources.MergedDictionaries;
|
||||
var tokensUri = new Uri($"/Themes/Tokens.{(light ? "Light" : "Dark")}.xaml", UriKind.Relative);
|
||||
for (var i = 0; i < dictionaries.Count; i++)
|
||||
{
|
||||
var source = dictionaries[i].Source?.OriginalString ?? string.Empty;
|
||||
if (source.EndsWith("Tokens.Dark.xaml", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.EndsWith("Tokens.Light.xaml", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
dictionaries[i] = new ResourceDictionary { Source = tokensUri };
|
||||
Save();
|
||||
return;
|
||||
}
|
||||
}
|
||||
dictionaries.Add(new ResourceDictionary { Source = tokensUri });
|
||||
Save();
|
||||
}
|
||||
|
||||
private static void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
|
||||
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(new { theme = IsLight ? "light" : "dark" }));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 저장 실패는 무시(다음 실행 기본 테마)
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 문서 스냅샷(메멘토) Undo/Redo — 직렬화 대신 모델 딥클론(편집 전용 상태 포함) 사용.
|
||||
/// 규약: 변경 '직전' Snapshot() 1회. 드래그는 첫 실이동에서 1회 = 드래그 전체가 1스텝.
|
||||
/// 방향키 넛지는 400ms 코얼레스.
|
||||
/// </summary>
|
||||
public sealed class UndoService
|
||||
{
|
||||
#region Member Fields
|
||||
private const int Capacity = 100;
|
||||
private readonly List<FormDocument> undoStack = new();
|
||||
private readonly List<FormDocument> redoStack = new();
|
||||
private readonly Func<FormDocument> getDocument;
|
||||
private readonly Action<FormDocument> restoreDocument;
|
||||
private DateTime lastNudgeAt = DateTime.MinValue;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>Undo 가능 여부</summary>
|
||||
public bool CanUndo => undoStack.Count > 0;
|
||||
|
||||
/// <summary>Redo 가능 여부</summary>
|
||||
public bool CanRedo => redoStack.Count > 0;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public UndoService(Func<FormDocument> getDocument, Action<FormDocument> restoreDocument)
|
||||
{
|
||||
this.getDocument = getDocument;
|
||||
this.restoreDocument = restoreDocument;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
/// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary>
|
||||
public event Action? Restored;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>변경 직전 스냅샷 적재 — redo 클리어</summary>
|
||||
public void Snapshot()
|
||||
{
|
||||
undoStack.Add(getDocument().Clone());
|
||||
if (undoStack.Count > Capacity)
|
||||
{
|
||||
undoStack.RemoveRange(0, undoStack.Count - Capacity);
|
||||
}
|
||||
redoStack.Clear();
|
||||
lastNudgeAt = DateTime.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary>
|
||||
public void SnapshotForNudge()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if ((now - lastNudgeAt).TotalMilliseconds > 400)
|
||||
{
|
||||
Snapshot();
|
||||
}
|
||||
lastNudgeAt = now;
|
||||
}
|
||||
|
||||
/// <summary>실행 취소</summary>
|
||||
public void Undo()
|
||||
{
|
||||
if (!CanUndo)
|
||||
{
|
||||
return;
|
||||
}
|
||||
redoStack.Add(getDocument().Clone());
|
||||
var snapshot = undoStack[^1];
|
||||
undoStack.RemoveAt(undoStack.Count - 1);
|
||||
restoreDocument(snapshot);
|
||||
Restored?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>다시 실행</summary>
|
||||
public void Redo()
|
||||
{
|
||||
if (!CanRedo)
|
||||
{
|
||||
return;
|
||||
}
|
||||
undoStack.Add(getDocument().Clone());
|
||||
var snapshot = redoStack[^1];
|
||||
redoStack.RemoveAt(redoStack.Count - 1);
|
||||
restoreDocument(snapshot);
|
||||
Restored?.Invoke();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWPF>true</UseWPF>
|
||||
<RootNamespace>SheetMe.Designer</RootNamespace>
|
||||
<AssemblyName>SheetMe.Designer</AssemblyName>
|
||||
<ApplicationIcon>Assets\sheetme.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="Assets\sheetme.ico" />
|
||||
<Resource Include="Assets\sheetme-logo.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SheetMe.Core\SheetMe.Core.csproj" />
|
||||
<ProjectReference Include="..\SheetMe.Data\SheetMe.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="M.Framework.WPF" Version="6.5.3" />
|
||||
<PackageReference Include="M.Framework.LogManager" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 실접속 정보는 Debug 빌드에서만 산출물에 복사한다 — Release 퍼블리시에 비밀값이 실리지 않게. -->
|
||||
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<None Update="appsettings.Development.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,806 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework">
|
||||
|
||||
<!-- ============================================================
|
||||
디자이너 공유 테마 (암시 스타일 + 컨트롤 템플릿)
|
||||
· 색상은 {DynamicResource B.*} 로 참조 → 토큰 dict(Tokens.Dark/Light.xaml) 교체로 라이트/다크 전환
|
||||
· MainWindow.Resources 에 [이 파일 + 토큰 dict] 머지(토큰 dict 만 스왑)
|
||||
· 스타일 키(Subtle/CaptionBtn/B.ComboItem/ScrollThumb…)는 StaticResource 유지
|
||||
============================================================ -->
|
||||
|
||||
<!-- ===== 창 공통(다이얼로그 배경/폰트 — 로컬 지정이 있으면 로컬 우선) ===== -->
|
||||
<Style TargetType="Window">
|
||||
<Setter Property="Background" Value="{DynamicResource B.Panel}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
</Style>
|
||||
|
||||
<!-- ===== 타이포 ===== -->
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
|
||||
</Style>
|
||||
|
||||
<!-- ===== 버튼 (기본: 은은한 다크 채움) ===== -->
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Input}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
|
||||
</Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.BtnHover}" />
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.BtnPressed}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="bd" Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 강조 버튼 -->
|
||||
<Style x:Key="Primary" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Background" Value="{DynamicResource B.Accent}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.OnAccent}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Accent}" />
|
||||
</Style>
|
||||
|
||||
<!-- 텍스트형(테두리 없는) 버튼 — 툴바 아이콘/네비 -->
|
||||
<Style x:Key="Subtle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
</Style>
|
||||
|
||||
<!-- ===== 텍스트박스 ===== -->
|
||||
<!-- Framer 식 입력칩: 채움만(평소 테두리 없음)·라운드8·높이30·세로 가운데 -->
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.InputBorder}" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Input}" />
|
||||
<Setter Property="Padding" Value="4,0" />
|
||||
<Setter Property="MinHeight" Value="30" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CaretBrush" Value="{DynamicResource B.Accent}" />
|
||||
<Setter Property="SelectionBrush" Value="{DynamicResource B.Accent}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="8" SnapsToDevicePixels="True">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
|
||||
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
|
||||
</Trigger>
|
||||
<Trigger Property="AcceptsReturn" Value="True">
|
||||
<Setter TargetName="PART_ContentHost" Property="VerticalContentAlignment" Value="Stretch" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Framer 식 슬라이더(불투명도 등): 얇은 트랙 + 흰 원형 썸 -->
|
||||
<Style TargetType="Slider">
|
||||
<Setter Property="MinHeight" Value="20" />
|
||||
<Setter Property="IsMoveToPointEnabled" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Slider">
|
||||
<Grid VerticalAlignment="Center">
|
||||
<Border Height="4" CornerRadius="2" Background="{DynamicResource B.Chip}" VerticalAlignment="Center" />
|
||||
<Track x:Name="PART_Track">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Command="Slider.DecreaseLarge" Focusable="False">
|
||||
<RepeatButton.Template>
|
||||
<ControlTemplate TargetType="RepeatButton">
|
||||
<Border Height="4" CornerRadius="2" Background="{DynamicResource B.Accent}" VerticalAlignment="Center" />
|
||||
</ControlTemplate>
|
||||
</RepeatButton.Template>
|
||||
</RepeatButton>
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Command="Slider.IncreaseLarge" Focusable="False">
|
||||
<RepeatButton.Template>
|
||||
<ControlTemplate TargetType="RepeatButton"><Border Background="Transparent" /></ControlTemplate>
|
||||
</RepeatButton.Template>
|
||||
</RepeatButton>
|
||||
</Track.IncreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb>
|
||||
<Thumb.Template>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Ellipse Width="13" Height="13" Fill="White" Stroke="{DynamicResource B.SliderRing}" StrokeThickness="1" />
|
||||
</ControlTemplate>
|
||||
</Thumb.Template>
|
||||
</Thumb>
|
||||
</Track.Thumb>
|
||||
</Track>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 리스트 ===== -->
|
||||
<Style TargetType="ListBox">
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
</Style>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="Padding" Value="6,5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="5" Padding="{TemplateBinding Padding}" Margin="2,1">
|
||||
<!-- string Content 가 앱 라이트 TextBlock 스타일(어두운색)로 떨어지지 않게 밝은색 지역 지정(ComboBoxItem 과 동일 패턴) -->
|
||||
<ContentPresenter TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock"><Setter Property="Foreground" Value="{DynamicResource B.Ink}" /></Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Sel}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 콤보박스 ===== -->
|
||||
<Style x:Key="B.ComboItem" TargetType="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Padding" Value="8,5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="4" Padding="{TemplateBinding Padding}" Margin="2,1">
|
||||
<!-- 생성되는 글자 TextBlock 이 앱 라이트 TextBlock 스타일(어두운색)로 떨어지지 않게, 여기서 밝은색 스타일을 지역 지정 -->
|
||||
<ContentPresenter>
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock"><Setter Property="Foreground" Value="{DynamicResource B.Ink}" /></Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Sel}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<!-- 전역 암시 콤보 항목(드롭다운 밖 용도 대비) — 키 스타일 상속 -->
|
||||
<Style TargetType="ComboBoxItem" BasedOn="{StaticResource B.ComboItem}" />
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Input}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Height" Value="30" />
|
||||
<!-- 드롭다운 항목 스타일을 컨테이너에 직접 지정(생성기가 적용 → 앱 라이트 ComboBoxItem 누수보다 우선) -->
|
||||
<Setter Property="ItemContainerStyle" Value="{StaticResource B.ComboItem}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton Focusable="False" ClickMode="Press"
|
||||
IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="bd" Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}"
|
||||
BorderThickness="1" CornerRadius="8" SnapsToDevicePixels="True">
|
||||
<Path x:Name="arr" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,9,0"
|
||||
Data="M0,0 L4,4 L8,0" Stroke="{DynamicResource B.Muted}" StrokeThickness="1.4"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
<ContentPresenter x:Name="cp" Margin="9,0,26,0" VerticalAlignment="Center" HorizontalAlignment="Left"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
TextElement.Foreground="{DynamicResource B.Ink}"
|
||||
IsHitTestVisible="False">
|
||||
<!-- 닫힌 표시 텍스트(SelectionBoxItem)가 앱 라이트 TextBlock 스타일(어두운색)로 떨어지지 않게 스코프 지정 -->
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock"><Setter Property="Foreground" Value="{DynamicResource B.Ink}" /></Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
<!-- 편집 가능 콤보(글꼴·형식 등)의 입력/표시 텍스트 -->
|
||||
<TextBox x:Name="PART_EditableTextBox" Margin="8,0,26,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource B.Ink}" CaretBrush="{DynamicResource B.Accent}"
|
||||
Background="Transparent" Visibility="Collapsed" IsReadOnly="{TemplateBinding IsReadOnly}">
|
||||
<TextBox.Template>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Focusable="False"
|
||||
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
|
||||
</ControlTemplate>
|
||||
</TextBox.Template>
|
||||
</TextBox>
|
||||
<!-- HorizontalOffset(-10)+좌우 Margin(10): 그림자 여백 확보하면서 드롭다운을 콤보 좌측에 정렬 -->
|
||||
<Popup IsOpen="{TemplateBinding IsDropDownOpen}" Placement="Bottom" AllowsTransparency="True"
|
||||
Focusable="False" PopupAnimation="Fade" HorizontalOffset="-10">
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
CornerRadius="5" Margin="10,3,10,12" Padding="2"
|
||||
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="14" ShadowDepth="2" Direction="270" Opacity="0.22" Color="#000000" RenderingBias="Quality"/>
|
||||
</Border.Effect>
|
||||
<!-- 드롭다운(별도 팝업)이 앱 라이트 ComboBoxItem 으로 새지 않게, 팝업 안에 다크 항목 스타일을 지역 지정 -->
|
||||
<Border.Resources>
|
||||
<Style TargetType="ComboBoxItem" BasedOn="{StaticResource B.ComboItem}" />
|
||||
</Border.Resources>
|
||||
<ScrollViewer MaxHeight="260"><ItemsPresenter /></ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsEditable" Value="True">
|
||||
<Setter TargetName="cp" Property="Visibility" Value="Collapsed" />
|
||||
<Setter TargetName="PART_EditableTextBox" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 탭 (좌측 패널) ===== -->
|
||||
<Style TargetType="TabControl">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabControl">
|
||||
<DockPanel>
|
||||
<!-- 세그먼트(알약) 탭 바 — FlutterFlow 식: 둥근 컨테이너 안에 알약 탭 -->
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource B.Panel}" BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8">
|
||||
<Border Background="{DynamicResource B.Chip}" CornerRadius="9" Padding="3">
|
||||
<UniformGrid Rows="1" IsItemsHost="True" />
|
||||
</Border>
|
||||
</Border>
|
||||
<ContentPresenter ContentSource="SelectedContent" />
|
||||
</DockPanel>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style TargetType="TabItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="6" Margin="2,0" Padding="6,6">
|
||||
<ContentPresenter ContentSource="Header" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.TabHover}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.TabSel}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 문서 탭 (중앙 상단 — 크롬식: 라운드 상단, 활성=Surface+테두리) ===== -->
|
||||
<Style x:Key="DocTabs" TargetType="TabControl">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabControl">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource B.Titlebar}"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8,5,8,0">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Bottom">
|
||||
<StackPanel Orientation="Horizontal" IsItemsHost="True" />
|
||||
<!-- 새 서식 탭(+) — 탭 흐름 바로 옆([200] 문서탭 관행) -->
|
||||
<Button Content="+" Command="{Binding NewFileCommand}" Style="{StaticResource Subtle}"
|
||||
Width="26" Height="24" Padding="0" Margin="2,0,0,3" FontSize="13"
|
||||
VerticalAlignment="Center" ToolTip="새 서식 탭"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<ContentPresenter ContentSource="SelectedContent" />
|
||||
</DockPanel>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="DocTabItem" TargetType="TabItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border x:Name="bd" CornerRadius="6,6,0,0" Padding="11,5,6,6" Margin="0,0,3,0"
|
||||
Background="Transparent" BorderBrush="Transparent" BorderThickness="1,1,1,0"
|
||||
SnapsToDevicePixels="True">
|
||||
<ContentPresenter ContentSource="Header" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.TabHover}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Surface}" />
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 좌측 탭 헤더 텍스트 — 미선택=Muted, 선택=Ink+Bold(알약 위 밝은 글씨). 템플릿 색 상속 불안정 우회 -->
|
||||
<Style x:Key="TabHeaderText" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=TabItem}}" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 토글 버튼 ===== -->
|
||||
<Style TargetType="ToggleButton">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
|
||||
</Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Sel}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="bd" Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 체크박스 ===== -->
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<StackPanel Orientation="Horizontal" Background="Transparent">
|
||||
<Border x:Name="box" Width="17" Height="17" CornerRadius="4" BorderThickness="1.3"
|
||||
BorderBrush="{DynamicResource B.Line2}" Background="{DynamicResource B.Input}" VerticalAlignment="Center" SnapsToDevicePixels="True">
|
||||
<Path x:Name="chk" Data="M 3.5 8 L 7 11 L 13 4.5" Stroke="{DynamicResource B.OnAccent}" StrokeThickness="2"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round" Visibility="Collapsed" />
|
||||
</Border>
|
||||
<ContentPresenter Margin="7,0,0,0" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="box" Property="Background" Value="{DynamicResource B.Accent}" />
|
||||
<Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource B.Accent}" />
|
||||
<Setter TargetName="chk" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource B.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 익스팬더 (인스펙터 섹션) ===== -->
|
||||
<Style TargetType="Expander">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Expander">
|
||||
<DockPanel Background="Transparent">
|
||||
<ToggleButton DockPanel.Dock="Top" Focusable="False" Cursor="Hand"
|
||||
Content="{TemplateBinding Header}" ContentTemplate="{TemplateBinding HeaderTemplate}"
|
||||
IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="hb" Background="Transparent" CornerRadius="5" Padding="2,6">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path x:Name="arrow" Data="M 0 0 L 4 4 L 8 0" Stroke="{DynamicResource B.Muted}" StrokeThickness="1.6"
|
||||
Margin="2,1,8,0" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
|
||||
<Path.RenderTransform><RotateTransform Angle="-90" /></Path.RenderTransform>
|
||||
</Path>
|
||||
<ContentPresenter VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="arrow" Property="RenderTransform">
|
||||
<Setter.Value><RotateTransform Angle="0" /></Setter.Value>
|
||||
</Setter>
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="hb" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
<ContentPresenter x:Name="content" DockPanel.Dock="Bottom" Visibility="Collapsed" Margin="8,2,2,10" />
|
||||
</DockPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsExpanded" Value="True">
|
||||
<Setter TargetName="content" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 카드 / 패널헤더 / 칩 ===== -->
|
||||
<Style x:Key="Card" TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="12" />
|
||||
</Style>
|
||||
<Style x:Key="PanelHeader" TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
</Style>
|
||||
<Style x:Key="Chip" TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource B.Chip}" />
|
||||
<Setter Property="CornerRadius" Value="20" />
|
||||
<Setter Property="Padding" Value="9,2" />
|
||||
</Style>
|
||||
|
||||
<!-- ===== 메뉴 (타이틀바) ===== -->
|
||||
<Style TargetType="Menu">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
</Style>
|
||||
<Style TargetType="Separator">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Separator">
|
||||
<Border Height="1" Background="{DynamicResource B.Line}" Margin="8,4" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style TargetType="MenuItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Padding" Value="10,6" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="MenuItem">
|
||||
<Grid TextElement.Foreground="{DynamicResource B.Ink}">
|
||||
<Border x:Name="bd" Background="Transparent" CornerRadius="4" Padding="{TemplateBinding Padding}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ContentPresenter Grid.Column="0" x:Name="icon" ContentSource="Icon" Margin="0,0,8,0" VerticalAlignment="Center" />
|
||||
<ContentPresenter Grid.Column="1" ContentSource="Header" VerticalAlignment="Center" RecognizesAccessKey="True" />
|
||||
<TextBlock Grid.Column="2" x:Name="gesture" Text="{TemplateBinding InputGestureText}" Foreground="{DynamicResource B.Muted}"
|
||||
Margin="20,0,0,0" VerticalAlignment="Center" />
|
||||
<Path Grid.Column="2" x:Name="sub" Visibility="Collapsed" Data="M0,0 L4,4 L0,8"
|
||||
Stroke="{DynamicResource B.Muted}" StrokeThickness="1.4" Margin="14,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Popup x:Name="pop" Placement="Bottom" IsOpen="{TemplateBinding IsSubmenuOpen}" AllowsTransparency="True"
|
||||
Focusable="False" PopupAnimation="Fade">
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="4" Margin="0,3,0,0">
|
||||
<StackPanel IsItemsHost="True" />
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="Role" Value="SubmenuHeader">
|
||||
<Setter TargetName="pop" Property="Placement" Value="Right" />
|
||||
<Setter TargetName="sub" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<!-- 최상위 메뉴(파일/편집…)는 아이콘·단축키 칸을 접어 폭을 좁힘 -->
|
||||
<Trigger Property="Role" Value="TopLevelHeader">
|
||||
<Setter TargetName="icon" Property="Visibility" Value="Collapsed" />
|
||||
<Setter TargetName="gesture" Property="Visibility" Value="Collapsed" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.4" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 우클릭 컨텍스트 메뉴 (다크 컨테이너) — 캔버스 우클릭 메뉴가 기본 라이트 팝업으로 안 깨지게 ===== -->
|
||||
<Style TargetType="ContextMenu">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ContextMenu">
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="4" SnapsToDevicePixels="True">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 창 캡션 버튼 (최소화/최대화/닫기) ===== -->
|
||||
<Style x:Key="CaptionBtn" TargetType="Button">
|
||||
<Setter Property="Width" Value="46" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.CaptionFg}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="Cursor" Value="Arrow" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextElement.FontFamily="{Binding FontFamily, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
TextElement.FontSize="{Binding FontSize, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource B.CaptionHover}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.CaptionFgHover}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
<Style x:Key="CloseBtn" TargetType="Button" BasedOn="{StaticResource CaptionBtn}">
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource B.CloseHover}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 컬럼 스플리터 (사이드바 너비 드래그 조절) ===== -->
|
||||
<Style x:Key="ColSplitter" TargetType="GridSplitter">
|
||||
<Setter Property="Width" Value="5" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="ResizeBehavior" Value="PreviousAndNext" />
|
||||
<Setter Property="ResizeDirection" Value="Columns" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="Cursor" Value="SizeWE" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="GridSplitter">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" SnapsToDevicePixels="True" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 행 스플리터 (팔레트 ↔ 레이어 높이 조절) ===== -->
|
||||
<Style x:Key="RowSplitter" TargetType="GridSplitter">
|
||||
<Setter Property="Height" Value="6" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="ResizeBehavior" Value="PreviousAndNext" />
|
||||
<Setter Property="ResizeDirection" Value="Rows" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Line}" />
|
||||
<Setter Property="Cursor" Value="SizeNS" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="GridSplitter">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" SnapsToDevicePixels="True" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== 스크롤바 (슬림 다크) — 모든 ScrollViewer/ListBox/캔버스에 일괄 적용 ===== -->
|
||||
<Style x:Key="ScrollThumb" TargetType="Thumb">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<!-- 막대 최소 크기 — 아이템이 많아도 실처럼 가늘어지지 않게(트랙보다 길면 WPF가 트랙 길이로 클램프) -->
|
||||
<Setter Property="MinHeight" Value="48" />
|
||||
<Setter Property="MinWidth" Value="48" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border x:Name="t" CornerRadius="4" Background="{DynamicResource B.ScrollThumb}" Margin="2" SnapsToDevicePixels="True" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="t" Property="Background" Value="{DynamicResource B.ScrollThumbHover}" /></Trigger>
|
||||
<Trigger Property="IsDragging" Value="True"><Setter TargetName="t" Property="Background" Value="{DynamicResource B.ScrollThumbDrag}" /></Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="ScrollPageBtn" TargetType="RepeatButton">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RepeatButton"><Border Background="Transparent" /></ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<!-- 슬림 스크롤바 — 전 ScrollViewer/ListBox 공통(암시 적용) -->
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Width" Value="11" />
|
||||
<Setter Property="MinWidth" Value="11" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Grid Background="{TemplateBinding Background}" SnapsToDevicePixels="True">
|
||||
<Track x:Name="PART_Track" Orientation="{TemplateBinding Orientation}" IsDirectionReversed="True">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Style="{StaticResource ScrollPageBtn}" Command="ScrollBar.PageUpCommand" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{StaticResource ScrollThumb}" />
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Style="{StaticResource ScrollPageBtn}" Command="ScrollBar.PageDownCommand" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter Property="Width" Value="Auto" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="Height" Value="11" />
|
||||
<Setter Property="MinHeight" Value="11" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,46 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ============================================================
|
||||
디자이너 다크 토큰 (Framer 다크 팔레트, framer.com 에디터 기준)
|
||||
· DesignerTheme.xaml 의 스타일이 {DynamicResource B.*} 로 참조
|
||||
· 라이트 대응본 = Tokens.Light.xaml (동일 키)
|
||||
============================================================ -->
|
||||
|
||||
<!-- 색상 토큰 -->
|
||||
<SolidColorBrush x:Key="B.Ink" Color="#EDEDED" />
|
||||
<SolidColorBrush x:Key="B.Muted" Color="#969696" />
|
||||
<SolidColorBrush x:Key="B.Line" Color="#272727" />
|
||||
<SolidColorBrush x:Key="B.Line2" Color="#383838" />
|
||||
<SolidColorBrush x:Key="B.Accent" Color="#0099FF" />
|
||||
<SolidColorBrush x:Key="B.Accent2" Color="#2A2A2A" />
|
||||
<SolidColorBrush x:Key="B.Sel" Color="#3C3C3C" /><!-- 선택/체크 강조 채움(입력칩 위에서 또렷) -->
|
||||
<SolidColorBrush x:Key="B.Hover" Color="#242424" />
|
||||
<SolidColorBrush x:Key="B.AppBg" Color="#0E0E0E" />
|
||||
<SolidColorBrush x:Key="B.Panel" Color="#151515" />
|
||||
<SolidColorBrush x:Key="B.PanelHeader" Color="#1C1C1C" />
|
||||
<SolidColorBrush x:Key="B.Surface" Color="#1C1C1C" />
|
||||
<SolidColorBrush x:Key="B.Titlebar" Color="#0E0E0E" />
|
||||
<SolidColorBrush x:Key="B.Input" Color="#262626" />
|
||||
<SolidColorBrush x:Key="B.InputBorder" Color="Transparent" /><!-- 다크는 입력 테두리 없음(Framer 식 채움만) -->
|
||||
<SolidColorBrush x:Key="B.CanvasBg" Color="#2A2A2A" />
|
||||
<SolidColorBrush x:Key="B.Chip" Color="#262626" />
|
||||
<SolidColorBrush x:Key="B.Success" Color="#4CAF7D" />
|
||||
<SolidColorBrush x:Key="B.Dark" Color="#0A0A0A" />
|
||||
|
||||
<!-- 트리거/템플릿에서 추출한 토큰(라이트 전환 위해) -->
|
||||
<SolidColorBrush x:Key="B.BtnHover" Color="#34363C" />
|
||||
<SolidColorBrush x:Key="B.BtnPressed" Color="#3C3F46" />
|
||||
<SolidColorBrush x:Key="B.TabHover" Color="#2C2C2C" />
|
||||
<SolidColorBrush x:Key="B.TabSel" Color="#353535" />
|
||||
<SolidColorBrush x:Key="B.OnAccent" Color="#FFFFFF" /><!-- 강조 채움 위 글자/체크마크(양 테마 흰색) -->
|
||||
<SolidColorBrush x:Key="B.ScrollThumb" Color="#454B54" />
|
||||
<SolidColorBrush x:Key="B.ScrollThumbHover" Color="#646C79" />
|
||||
<SolidColorBrush x:Key="B.ScrollThumbDrag" Color="#7E8693" />
|
||||
<SolidColorBrush x:Key="B.CaptionFg" Color="#C7CDD4" />
|
||||
<SolidColorBrush x:Key="B.CaptionFgHover" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="B.CaptionHover" Color="#2E3138" />
|
||||
<SolidColorBrush x:Key="B.CloseHover" Color="#E04A2B" />
|
||||
<SolidColorBrush x:Key="B.SliderRing" Color="Transparent" /><!-- 다크는 테두리 없음(흰 썸 그대로) -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,46 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ============================================================
|
||||
디자이너 라이트 토큰 — Tokens.Dark.xaml 과 동일 키, 라이트값
|
||||
· Framer 라이트 에디터 기준: 중립 회색(쿨/블루톤 제거) + 강조는 Framer 블루 #0099FF
|
||||
· 회색은 무채색(R=G=B)로 통일, 블루 틴트는 선택/활성(Accent2/Sel)에만
|
||||
============================================================ -->
|
||||
|
||||
<!-- 색상 토큰 -->
|
||||
<SolidColorBrush x:Key="B.Ink" Color="#222222" />
|
||||
<SolidColorBrush x:Key="B.Muted" Color="#969696" />
|
||||
<SolidColorBrush x:Key="B.Line" Color="#EAEAEA" />
|
||||
<SolidColorBrush x:Key="B.Line2" Color="#D8D8D8" />
|
||||
<SolidColorBrush x:Key="B.Accent" Color="#0099FF" />
|
||||
<SolidColorBrush x:Key="B.Accent2" Color="#E4F2FF" /><!-- 은은한 활성 틴트(연블루) -->
|
||||
<SolidColorBrush x:Key="B.Sel" Color="#DBEEFF" /><!-- 선택 채움(입력칩 위 또렷한 블루틴트) -->
|
||||
<SolidColorBrush x:Key="B.Hover" Color="#F3F3F3" />
|
||||
<SolidColorBrush x:Key="B.AppBg" Color="#F5F5F5" />
|
||||
<SolidColorBrush x:Key="B.Panel" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="B.PanelHeader" Color="#FAFAFA" />
|
||||
<SolidColorBrush x:Key="B.Surface" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="B.Titlebar" Color="#F7F7F7" /><!-- 순백 아님(패널과 미묘한 분리) -->
|
||||
<SolidColorBrush x:Key="B.Input" Color="#FFFFFF" /><!-- 입력칩=흰색(라이트). 옅은 테두리 B.InputBorder 동반 -->
|
||||
<SolidColorBrush x:Key="B.InputBorder" Color="#E3E3E3" /><!-- 흰 입력칩 가장자리 -->
|
||||
<SolidColorBrush x:Key="B.CanvasBg" Color="#ECECEC" />
|
||||
<SolidColorBrush x:Key="B.Chip" Color="#EFEFEF" />
|
||||
<SolidColorBrush x:Key="B.Success" Color="#2E9E6B" />
|
||||
<SolidColorBrush x:Key="B.Dark" Color="#1A1A1A" />
|
||||
|
||||
<!-- 트리거/템플릿 추출 토큰 -->
|
||||
<SolidColorBrush x:Key="B.BtnHover" Color="#F3F3F3" />
|
||||
<SolidColorBrush x:Key="B.BtnPressed" Color="#EAEAEA" />
|
||||
<SolidColorBrush x:Key="B.TabHover" Color="#F0F0F0" />
|
||||
<SolidColorBrush x:Key="B.TabSel" Color="#E8E8E8" />
|
||||
<SolidColorBrush x:Key="B.OnAccent" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="B.ScrollThumb" Color="#CDCDCD" />
|
||||
<SolidColorBrush x:Key="B.ScrollThumbHover" Color="#B6B6B6" />
|
||||
<SolidColorBrush x:Key="B.ScrollThumbDrag" Color="#A2A2A2" />
|
||||
<SolidColorBrush x:Key="B.CaptionFg" Color="#444444" />
|
||||
<SolidColorBrush x:Key="B.CaptionFgHover" Color="#1A1A1A" />
|
||||
<SolidColorBrush x:Key="B.CaptionHover" Color="#E8E8E8" />
|
||||
<SolidColorBrush x:Key="B.CloseHover" Color="#E04A2B" />
|
||||
<SolidColorBrush x:Key="B.SliderRing" Color="#CFCFCF" /><!-- 라이트는 흰 썸 가장자리 링 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Figma 식 색상 피커([200]SheetMe ColorPickerWindow 이식) — 채도/명도(SV) 사각형 + 색상(Hue) 슬라이더
|
||||
/// + HEX·RGB 입력 + 프리셋/최근색. 정적 <see cref="Pick"/> 호출: 확정 시 "#RRGGBB" 반환, 취소 시 null.
|
||||
/// 셸 색은 앱 테마 토큰(B.*) — SV/Hue/스와치는 색상 원본 그대로.
|
||||
/// </summary>
|
||||
public sealed class ColorPickerWindow : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly List<string> recent = new();
|
||||
|
||||
private double hue360; // H 0..360
|
||||
private double sat; // S 0..1
|
||||
private double val; // V 0..1
|
||||
private bool syncing; // 텍스트박스 ↔ 상태 갱신 재진입 방지
|
||||
|
||||
private readonly Canvas svArea = new() { Width = 248, Height = 168, ClipToBounds = true, Cursor = Cursors.Cross };
|
||||
private readonly Rectangle svHue = new() { Width = 248, Height = 168 };
|
||||
private readonly Ellipse svThumb = new() { Width = 14, Height = 14, Stroke = Brushes.White, StrokeThickness = 2, IsHitTestVisible = false };
|
||||
private readonly Canvas hueArea = new() { Width = 248, Height = 16, ClipToBounds = true, Cursor = Cursors.Cross };
|
||||
private readonly Border hueThumb = new() { Width = 6, Height = 20, BorderBrush = Brushes.White, BorderThickness = new Thickness(2), CornerRadius = new CornerRadius(2), IsHitTestVisible = false };
|
||||
private readonly Border preview = new() { Width = 40, Height = 40, CornerRadius = new CornerRadius(6), BorderThickness = new Thickness(1) };
|
||||
private readonly TextBox hexBox = new() { Width = 92, VerticalContentAlignment = VerticalAlignment.Center };
|
||||
private readonly TextBox rBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
|
||||
private readonly TextBox gBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
|
||||
private readonly TextBox bBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>확정된 "#RRGGBB"(취소 시 null)</summary>
|
||||
public string? Result { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>색상 선택 대화상자 — 확정 시 "#RRGGBB", 취소 시 null. initialHex 로 초기색 지정.</summary>
|
||||
public static string? Pick(Window? owner, string? initialHex)
|
||||
{
|
||||
var window = new ColorPickerWindow(initialHex);
|
||||
if (owner is not null)
|
||||
{
|
||||
window.Owner = owner;
|
||||
}
|
||||
return window.ShowDialog() == true ? window.Result : null;
|
||||
}
|
||||
|
||||
private ColorPickerWindow(string? initialHex)
|
||||
{
|
||||
Title = "색상 선택";
|
||||
Width = 300;
|
||||
SizeToContent = SizeToContent.Height;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
ResizeMode = ResizeMode.NoResize;
|
||||
ShowInTaskbar = false;
|
||||
FontSize = 12.5;
|
||||
|
||||
var line = FindBrush("B.Line");
|
||||
var muted = FindBrush("B.Muted");
|
||||
preview.BorderBrush = line;
|
||||
|
||||
var initial = ParseHex(initialHex) ?? Color.FromRgb(0x2F, 0x6D, 0xF0);
|
||||
(hue360, sat, val) = RgbToHsv(initial.R, initial.G, initial.B);
|
||||
|
||||
var root = new StackPanel { Margin = new Thickness(14) };
|
||||
|
||||
// ── SV 사각형(흰색→hue 가로 + 투명→검정 세로) ──
|
||||
svArea.Children.Add(svHue);
|
||||
svArea.Children.Add(new Rectangle
|
||||
{
|
||||
Width = 248, Height = 168,
|
||||
Fill = new LinearGradientBrush(Color.FromArgb(255, 255, 255, 255), Color.FromArgb(0, 255, 255, 255), new Point(0, 0), new Point(1, 0)),
|
||||
});
|
||||
svArea.Children.Add(new Rectangle
|
||||
{
|
||||
Width = 248, Height = 168,
|
||||
Fill = new LinearGradientBrush(Color.FromArgb(0, 0, 0, 0), Color.FromArgb(255, 0, 0, 0), new Point(0, 0), new Point(0, 1)),
|
||||
});
|
||||
svArea.Children.Add(svThumb);
|
||||
svArea.MouseLeftButtonDown += (_, e) => { svArea.CaptureMouse(); UpdateSvFrom(e.GetPosition(svArea)); };
|
||||
svArea.MouseMove += (_, e) => { if (e.LeftButton == MouseButtonState.Pressed && svArea.IsMouseCaptured) UpdateSvFrom(e.GetPosition(svArea)); };
|
||||
svArea.MouseLeftButtonUp += (_, _) => svArea.ReleaseMouseCapture();
|
||||
root.Children.Add(new Border { Child = svArea, CornerRadius = new CornerRadius(6), ClipToBounds = true, Margin = new Thickness(0, 0, 0, 10) });
|
||||
|
||||
// ── Hue 슬라이더(무지개) ──
|
||||
hueArea.Children.Add(new Rectangle
|
||||
{
|
||||
Width = 248, Height = 16,
|
||||
Fill = new LinearGradientBrush(new GradientStopCollection
|
||||
{
|
||||
new(Color.FromRgb(255, 0, 0), 0), new(Color.FromRgb(255, 255, 0), 1 / 6.0), new(Color.FromRgb(0, 255, 0), 2 / 6.0),
|
||||
new(Color.FromRgb(0, 255, 255), 3 / 6.0), new(Color.FromRgb(0, 0, 255), 4 / 6.0), new(Color.FromRgb(255, 0, 255), 5 / 6.0), new(Color.FromRgb(255, 0, 0), 1),
|
||||
}, new Point(0, 0), new Point(1, 0)),
|
||||
RadiusX = 8, RadiusY = 8,
|
||||
});
|
||||
hueArea.Children.Add(hueThumb);
|
||||
Canvas.SetTop(hueThumb, -2);
|
||||
hueArea.MouseLeftButtonDown += (_, e) => { hueArea.CaptureMouse(); UpdateHueFrom(e.GetPosition(hueArea)); };
|
||||
hueArea.MouseMove += (_, e) => { if (e.LeftButton == MouseButtonState.Pressed && hueArea.IsMouseCaptured) UpdateHueFrom(e.GetPosition(hueArea)); };
|
||||
hueArea.MouseLeftButtonUp += (_, _) => hueArea.ReleaseMouseCapture();
|
||||
root.Children.Add(new Border { Child = hueArea, Margin = new Thickness(0, 0, 0, 12) });
|
||||
|
||||
// ── 미리보기 + HEX / RGB ──
|
||||
var inputs = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 12) };
|
||||
inputs.Children.Add(preview);
|
||||
var fields = new StackPanel { Margin = new Thickness(12, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
|
||||
var hexRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
|
||||
hexRow.Children.Add(new TextBlock { Text = "HEX", Width = 30, VerticalAlignment = VerticalAlignment.Center, Foreground = muted });
|
||||
hexRow.Children.Add(hexBox);
|
||||
fields.Children.Add(hexRow);
|
||||
var rgbRow = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
rgbRow.Children.Add(new TextBlock { Text = "RGB", Width = 30, VerticalAlignment = VerticalAlignment.Center, Foreground = muted });
|
||||
rgbRow.Children.Add(rBox);
|
||||
rgbRow.Children.Add(new Border { Width = 4 });
|
||||
rgbRow.Children.Add(gBox);
|
||||
rgbRow.Children.Add(new Border { Width = 4 });
|
||||
rgbRow.Children.Add(bBox);
|
||||
fields.Children.Add(rgbRow);
|
||||
inputs.Children.Add(fields);
|
||||
root.Children.Add(inputs);
|
||||
|
||||
hexBox.LostKeyboardFocus += (_, _) => CommitHex();
|
||||
hexBox.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitHex(); };
|
||||
foreach (var box in new[] { rBox, gBox, bBox })
|
||||
{
|
||||
box.LostKeyboardFocus += (_, _) => CommitRgb();
|
||||
box.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitRgb(); };
|
||||
}
|
||||
|
||||
// ── 프리셋 + 최근색 ──
|
||||
root.Children.Add(SwatchGrid("프리셋", new[]
|
||||
{
|
||||
"#000000", "#374151", "#6B727B", "#C7CDD4", "#FFFFFF", "#C0392B", "#E67E22", "#F1C40F",
|
||||
"#1F9D55", "#16A085", "#2F6DF0", "#2980B9", "#8E44AD", "#EC4899", "#7F1D1D", "#FDE68A",
|
||||
}));
|
||||
if (recent.Count > 0)
|
||||
{
|
||||
root.Children.Add(SwatchGrid("최근 사용", recent.ToArray()));
|
||||
}
|
||||
|
||||
// ── 버튼 ──
|
||||
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
|
||||
var ok = new Button { Content = "적용", MinWidth = 64, Margin = new Thickness(0, 0, 8, 0), IsDefault = true };
|
||||
try { ok.Style = (Style)FindResource("Primary"); } catch { /* 테마 미로드 시 기본 */ }
|
||||
ok.Click += (_, _) => { Result = CurrentHex(); AddRecent(Result); DialogResult = true; };
|
||||
var cancel = new Button { Content = "취소", MinWidth = 64, IsCancel = true };
|
||||
cancel.Click += (_, _) => DialogResult = false;
|
||||
buttons.Children.Add(ok);
|
||||
buttons.Children.Add(cancel);
|
||||
root.Children.Add(buttons);
|
||||
|
||||
Content = root;
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private FrameworkElement SwatchGrid(string title, string[] hexes)
|
||||
{
|
||||
var section = new StackPanel { Margin = new Thickness(0, 2, 0, 0) };
|
||||
section.Children.Add(new TextBlock { Text = title, FontSize = 11, Foreground = FindBrush("B.Muted"), Margin = new Thickness(0, 0, 0, 4) });
|
||||
var wrap = new WrapPanel();
|
||||
foreach (var hex in hexes)
|
||||
{
|
||||
var swatch = new Button
|
||||
{
|
||||
Width = 22, Height = 22, Margin = new Thickness(0, 0, 4, 4), Padding = new Thickness(0),
|
||||
ToolTip = hex, BorderBrush = FindBrush("B.Line2"),
|
||||
Background = new SolidColorBrush(ParseHex(hex) ?? Colors.White),
|
||||
};
|
||||
var captured = hex;
|
||||
swatch.Click += (_, _) =>
|
||||
{
|
||||
if (ParseHex(captured) is not { } color)
|
||||
{
|
||||
return;
|
||||
}
|
||||
(hue360, sat, val) = RgbToHsv(color.R, color.G, color.B);
|
||||
RefreshAll();
|
||||
};
|
||||
wrap.Children.Add(swatch);
|
||||
}
|
||||
section.Children.Add(wrap);
|
||||
return section;
|
||||
}
|
||||
|
||||
private static Brush FindBrush(string key)
|
||||
=> Application.Current.TryFindResource(key) as Brush ?? Brushes.Gray;
|
||||
|
||||
// ── 상호작용 → 상태 ──
|
||||
private void UpdateSvFrom(Point point)
|
||||
{
|
||||
sat = Math.Clamp(point.X / svArea.Width, 0, 1);
|
||||
val = 1 - Math.Clamp(point.Y / svArea.Height, 0, 1);
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private void UpdateHueFrom(Point point)
|
||||
{
|
||||
hue360 = Math.Clamp(point.X / hueArea.Width, 0, 1) * 360;
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private void CommitHex()
|
||||
{
|
||||
if (syncing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (ParseHex(hexBox.Text) is not { } color)
|
||||
{
|
||||
RefreshAll();
|
||||
return;
|
||||
}
|
||||
(hue360, sat, val) = RgbToHsv(color.R, color.G, color.B);
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private void CommitRgb()
|
||||
{
|
||||
if (syncing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (byte.TryParse(rBox.Text, out var r) && byte.TryParse(gBox.Text, out var g) && byte.TryParse(bBox.Text, out var b))
|
||||
{
|
||||
(hue360, sat, val) = RgbToHsv(r, g, b);
|
||||
}
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
// ── 상태 → 화면 ──
|
||||
private void RefreshAll()
|
||||
{
|
||||
var (hueR, hueG, hueB) = HsvToRgb(hue360, 1, 1);
|
||||
svHue.Fill = new SolidColorBrush(Color.FromRgb(hueR, hueG, hueB));
|
||||
Canvas.SetLeft(svThumb, sat * svArea.Width - 7);
|
||||
Canvas.SetTop(svThumb, (1 - val) * svArea.Height - 7);
|
||||
var (r, g, b) = HsvToRgb(hue360, sat, val);
|
||||
svThumb.Fill = new SolidColorBrush(Color.FromRgb(r, g, b));
|
||||
Canvas.SetLeft(hueThumb, hue360 / 360 * hueArea.Width - 3);
|
||||
|
||||
preview.Background = new SolidColorBrush(Color.FromRgb(r, g, b));
|
||||
|
||||
syncing = true;
|
||||
hexBox.Text = CurrentHex();
|
||||
rBox.Text = r.ToString();
|
||||
gBox.Text = g.ToString();
|
||||
bBox.Text = b.ToString();
|
||||
syncing = false;
|
||||
}
|
||||
|
||||
private string CurrentHex()
|
||||
{
|
||||
var (r, g, b) = HsvToRgb(hue360, sat, val);
|
||||
return $"#{r:X2}{g:X2}{b:X2}";
|
||||
}
|
||||
|
||||
private static void AddRecent(string? hex)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hex))
|
||||
{
|
||||
return;
|
||||
}
|
||||
recent.Remove(hex);
|
||||
recent.Insert(0, hex);
|
||||
while (recent.Count > 16)
|
||||
{
|
||||
recent.RemoveAt(recent.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 색 변환 ──
|
||||
private static Color? ParseHex(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var s = text.Trim().TrimStart('#');
|
||||
if (s.Length == 3)
|
||||
{
|
||||
s = $"{s[0]}{s[0]}{s[1]}{s[1]}{s[2]}{s[2]}";
|
||||
}
|
||||
if (s.Length != 6)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return int.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
|
||||
? Color.FromRgb((byte)((value >> 16) & 0xFF), (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF))
|
||||
: null;
|
||||
}
|
||||
|
||||
private static (double H, double S, double V) RgbToHsv(byte r, byte g, byte b)
|
||||
{
|
||||
double rd = r / 255.0, gd = g / 255.0, bd = b / 255.0;
|
||||
double max = Math.Max(rd, Math.Max(gd, bd)), min = Math.Min(rd, Math.Min(gd, bd));
|
||||
double d = max - min, h = 0;
|
||||
if (d != 0)
|
||||
{
|
||||
if (max == rd)
|
||||
{
|
||||
h = 60 * (((gd - bd) / d) % 6);
|
||||
}
|
||||
else if (max == gd)
|
||||
{
|
||||
h = 60 * (((bd - rd) / d) + 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
h = 60 * (((rd - gd) / d) + 4);
|
||||
}
|
||||
}
|
||||
if (h < 0)
|
||||
{
|
||||
h += 360;
|
||||
}
|
||||
return (h, max == 0 ? 0 : d / max, max);
|
||||
}
|
||||
|
||||
private static (byte R, byte G, byte B) HsvToRgb(double h, double s, double v)
|
||||
{
|
||||
double c = v * s, x = c * (1 - Math.Abs((h / 60 % 2) - 1)), m = v - c;
|
||||
double r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; }
|
||||
else if (h < 120) { r = x; g = c; }
|
||||
else if (h < 180) { g = c; b = x; }
|
||||
else if (h < 240) { g = x; b = c; }
|
||||
else if (h < 300) { r = x; b = c; }
|
||||
else { r = c; b = x; }
|
||||
return ((byte)Math.Round((r + m) * 255), (byte)Math.Round((g + m) * 255), (byte)Math.Round((b + m) * 255));
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<UserControl x:Class="SheetMe.Designer.Views.DesignerCanvasView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
|
||||
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
||||
xmlns:v="clr-namespace:SheetMe.Designer.Views"
|
||||
xmlns:bh="clr-namespace:SheetMe.Designer.Behaviors"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance vm:DesignerViewModel}"
|
||||
Focusable="True" FocusVisualStyle="{x:Null}"
|
||||
PreviewKeyDown="OnCanvasPreviewKeyDown" PreviewKeyUp="OnCanvasPreviewKeyUp">
|
||||
|
||||
<!-- 키보드 단축키(텍스트 입력 보호 포함) -->
|
||||
<b:Interaction.Behaviors>
|
||||
<bh:CanvasKeyboardBehavior/>
|
||||
</b:Interaction.Behaviors>
|
||||
|
||||
<!-- 디자인 캔버스: 스크롤 + 줌(LayoutTransform) 월드. 입력은 World 1곳에서 수신 -->
|
||||
<ScrollViewer x:Name="Scroll"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Background="{DynamicResource B.CanvasBg}"
|
||||
Focusable="False"
|
||||
PreviewMouseWheel="OnPreviewMouseWheel"
|
||||
PreviewMouseLeftButtonDown="OnPanMouseDown"
|
||||
PreviewMouseMove="OnPanMouseMove"
|
||||
PreviewMouseLeftButtonUp="OnPanMouseUp"
|
||||
LostMouseCapture="OnPanLostCapture">
|
||||
<Border Padding="24">
|
||||
<Grid x:Name="World"
|
||||
Background="Transparent"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
Width="{Binding WorldWidth}" Height="{Binding WorldHeight}">
|
||||
<Grid.LayoutTransform>
|
||||
<ScaleTransform ScaleX="{Binding Zoom}" ScaleY="{Binding Zoom}"/>
|
||||
</Grid.LayoutTransform>
|
||||
|
||||
<b:Interaction.Behaviors>
|
||||
<bh:CanvasMouseBehavior/>
|
||||
<bh:CanvasDropBehavior/>
|
||||
</b:Interaction.Behaviors>
|
||||
|
||||
<!-- 층1: 페이지 스택 -->
|
||||
<ItemsControl ItemsSource="{Binding Pages}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="0"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding OffsetY}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<v:PageView/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 층2: 선택/마퀴/가이드 오버레이 (표시 전용) -->
|
||||
<v:SelectionOverlayView DataContext="{Binding Overlay}"/>
|
||||
|
||||
<!-- 층3: 인라인 텍스트 에디터 (더블클릭 시 코드비하인드가 배치) -->
|
||||
<Canvas x:Name="EditorLayer"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,294 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 디자인 캔버스 뷰 — 코드비하인드 허용 범위: ①줌 휠 처리(커서 중심 스크롤 보정) ②캔버스 포커스 ③인라인 텍스트 에디터.
|
||||
/// 문서 변경 로직은 두지 않는다(커밋은 DesignerViewModel.CommitInlineText 경유).
|
||||
/// </summary>
|
||||
public partial class DesignerCanvasView : UserControl
|
||||
{
|
||||
#region Member Fields
|
||||
private DesignerViewModel? subscribedDesigner;
|
||||
private TextBox? inlineEditor;
|
||||
private ControlViewModel? editingTarget;
|
||||
|
||||
// 손(팬) 도구 — 플로팅 바 토글 또는 Space 누르는 동안
|
||||
private bool isPanning;
|
||||
private bool spacePanHeld;
|
||||
private Point panStartPoint;
|
||||
private double panStartHorizontal;
|
||||
private double panStartVertical;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public DesignerCanvasView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - 인라인 에디터
|
||||
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (subscribedDesigner is not null)
|
||||
{
|
||||
subscribedDesigner.InlineEditRequested -= ShowInlineEditor;
|
||||
}
|
||||
CloseInlineEditor(commit: false);
|
||||
subscribedDesigner = e.NewValue as DesignerViewModel;
|
||||
if (subscribedDesigner is not null)
|
||||
{
|
||||
subscribedDesigner.InlineEditRequested += ShowInlineEditor;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>대상 컨트롤 위에 편집기 표시 — 폰트/정렬 일치, 줌은 World LayoutTransform 이 처리</summary>
|
||||
private void ShowInlineEditor(ControlViewModel target)
|
||||
{
|
||||
if (subscribedDesigner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
CloseInlineEditor(commit: true);
|
||||
|
||||
// 데이터소스(MDataTable)는 인라인 대신 전용 쿼리 편집기 창
|
||||
if (target is DataTableViewModel dataTable)
|
||||
{
|
||||
var queryDialog = new QueryEditorWindow(dataTable.Id, dataTable.Model.Props.GetText("Query") ?? string.Empty)
|
||||
{
|
||||
Owner = Window.GetWindow(this),
|
||||
};
|
||||
if (queryDialog.ShowDialog() == true)
|
||||
{
|
||||
subscribedDesigner.CommitPropertyText(dataTable, "Query", queryDialog.QueryText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var bounds = subscribedDesigner.WorldBoundsOf(target);
|
||||
var multiline = target is LabelViewModel
|
||||
|| (target is TextBoxViewModel textBox && textBox.Multiline);
|
||||
|
||||
var editor = new TextBox
|
||||
{
|
||||
// 종이 위 텍스트와 픽셀 일치가 목적 — 앱 테마의 암시 TextBox 템플릿(입력칩 라운드/MinHeight) 차단
|
||||
Style = new Style(typeof(TextBox)),
|
||||
Text = target.Text,
|
||||
FontFamily = target.FontFamily,
|
||||
FontSize = target.FontSize,
|
||||
FontWeight = target.FontWeight,
|
||||
FontStyle = target.FontStyle,
|
||||
Foreground = target.Foreground,
|
||||
Background = Brushes.White,
|
||||
BorderBrush = new SolidColorBrush(Color.FromRgb(0x1E, 0x7B, 0xE8)),
|
||||
BorderThickness = new Thickness(1),
|
||||
Padding = new Thickness(1, 0, 1, 0),
|
||||
MinWidth = Math.Max(40, bounds.Width),
|
||||
MinHeight = bounds.Height,
|
||||
MaxWidth = 640,
|
||||
AcceptsReturn = multiline,
|
||||
TextWrapping = multiline ? TextWrapping.Wrap : TextWrapping.NoWrap,
|
||||
VerticalContentAlignment = multiline ? VerticalAlignment.Top : VerticalAlignment.Center,
|
||||
ToolTip = multiline ? "Enter 확정 · Shift+Enter 줄바꿈 · Esc 취소" : "Enter 확정 · Esc 취소",
|
||||
};
|
||||
Canvas.SetLeft(editor, bounds.X);
|
||||
Canvas.SetTop(editor, bounds.Y);
|
||||
|
||||
editor.PreviewKeyDown += OnEditorKeyDown;
|
||||
editor.LostKeyboardFocus += OnEditorLostFocus;
|
||||
|
||||
editingTarget = target;
|
||||
inlineEditor = editor;
|
||||
EditorLayer.Children.Add(editor);
|
||||
|
||||
// 레이아웃 이후 포커스 + 전체 선택
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, () =>
|
||||
{
|
||||
editor.Focus();
|
||||
editor.SelectAll();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnEditorKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (inlineEditor is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
e.Handled = true;
|
||||
CloseInlineEditor(commit: false);
|
||||
Focus();
|
||||
}
|
||||
else if (e.Key == Key.Enter && !Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
|
||||
{
|
||||
// Enter=확정, Shift+Enter=줄바꿈(여러 줄 편집기)
|
||||
e.Handled = true;
|
||||
CloseInlineEditor(commit: true);
|
||||
Focus();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEditorLostFocus(object sender, KeyboardFocusChangedEventArgs e)
|
||||
=> CloseInlineEditor(commit: true);
|
||||
|
||||
private void CloseInlineEditor(bool commit)
|
||||
{
|
||||
if (inlineEditor is null || editingTarget is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var editor = inlineEditor;
|
||||
var target = editingTarget;
|
||||
inlineEditor = null;
|
||||
editingTarget = null;
|
||||
|
||||
editor.PreviewKeyDown -= OnEditorKeyDown;
|
||||
editor.LostKeyboardFocus -= OnEditorLostFocus;
|
||||
EditorLayer.Children.Remove(editor);
|
||||
|
||||
if (commit)
|
||||
{
|
||||
subscribedDesigner?.CommitInlineText(target, editor.Text);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - 손(팬) 도구
|
||||
/// <summary>손 도구 활성 여부 — 플로팅 바 토글([200] 관행) 또는 Space 누르는 동안</summary>
|
||||
private bool IsHandToolActive =>
|
||||
spacePanHeld
|
||||
|| (Application.Current.MainWindow?.DataContext as MainViewModel)?.IsHandTool == true;
|
||||
|
||||
/// <summary>팬 시작 — Preview 단계 선점으로 하위 선택/이동 로직(World 버블링) 차단</summary>
|
||||
private void OnPanMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (!IsHandToolActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
isPanning = true;
|
||||
panStartPoint = e.GetPosition(Scroll);
|
||||
panStartHorizontal = Scroll.HorizontalOffset;
|
||||
panStartVertical = Scroll.VerticalOffset;
|
||||
Scroll.CaptureMouse();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnPanMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
// 손 모드 커서 — 하위(World) 히트테스트 커서보다 우선하도록 ForceCursor
|
||||
if (IsHandToolActive)
|
||||
{
|
||||
Scroll.Cursor = Cursors.Hand;
|
||||
Scroll.ForceCursor = true;
|
||||
}
|
||||
else if (Scroll.ForceCursor)
|
||||
{
|
||||
Scroll.ForceCursor = false;
|
||||
Scroll.Cursor = null;
|
||||
}
|
||||
|
||||
if (isPanning)
|
||||
{
|
||||
var position = e.GetPosition(Scroll);
|
||||
Scroll.ScrollToHorizontalOffset(panStartHorizontal - (position.X - panStartPoint.X));
|
||||
Scroll.ScrollToVerticalOffset(panStartVertical - (position.Y - panStartPoint.Y));
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (IsHandToolActive)
|
||||
{
|
||||
e.Handled = true; // 손 모드 유휴 이동도 선점 — World 커서 갱신 억제
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPanMouseUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (!isPanning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
isPanning = false;
|
||||
Scroll.ReleaseMouseCapture();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnPanLostCapture(object sender, MouseEventArgs e) => isPanning = false;
|
||||
|
||||
/// <summary>Space 누르는 동안 임시 손 도구 — 텍스트 입력 중에는 개입하지 않음</summary>
|
||||
private void OnCanvasPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Space && !e.IsRepeat && e.OriginalSource is not TextBox)
|
||||
{
|
||||
spacePanHeld = true;
|
||||
Scroll.Cursor = Cursors.Hand;
|
||||
Scroll.ForceCursor = true;
|
||||
e.Handled = true; // ScrollViewer 의 Space 페이지 스크롤 기본 동작 차단
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCanvasPreviewKeyUp(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Space && spacePanHeld)
|
||||
{
|
||||
spacePanHeld = false;
|
||||
if (!IsHandToolActive)
|
||||
{
|
||||
Scroll.ForceCursor = false;
|
||||
Scroll.Cursor = null;
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - 줌
|
||||
/// <summary>Ctrl+휠 줌 — 커서 논리 위치를 유지하도록 스크롤 오프셋 보정</summary>
|
||||
private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
if (Keyboard.Modifiers != ModifierKeys.Control)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (DataContext is not DesignerViewModel designer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
|
||||
var oldZoom = designer.Zoom;
|
||||
if (e.Delta > 0)
|
||||
{
|
||||
designer.ZoomIn();
|
||||
}
|
||||
else
|
||||
{
|
||||
designer.ZoomOut();
|
||||
}
|
||||
|
||||
var newZoom = designer.Zoom;
|
||||
if (Math.Abs(newZoom - oldZoom) < 0.0001)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 커서가 가리키는 논리 지점이 화면상 같은 곳에 남도록 오프셋 보정 (레이아웃 갱신 후)
|
||||
var mouse = e.GetPosition(Scroll);
|
||||
var factor = newZoom / oldZoom;
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, () =>
|
||||
{
|
||||
Scroll.ScrollToHorizontalOffset((Scroll.HorizontalOffset + mouse.X) * factor - mouse.X);
|
||||
Scroll.ScrollToVerticalOffset((Scroll.VerticalOffset + mouse.Y) * factor - mouse.Y);
|
||||
});
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.FontManagerDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="폰트 일괄 변경" Width="620" Height="560" MinWidth="520" MinHeight="420"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
|
||||
Text="문서 전체의 (타입·글꼴·크기) 조합입니다. 변경할 조합을 선택(다중 가능)하고 새 글꼴을 지정하세요."/>
|
||||
|
||||
<!-- 새 글꼴 지정 + 적용 -->
|
||||
<Border DockPanel.Dock="Bottom" Background="{DynamicResource B.Chip}" CornerRadius="4" Padding="10" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<DockPanel>
|
||||
<TextBlock Text="새 글꼴" Width="60" VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="FamilyBox" IsEditable="True" Width="180" HorizontalAlignment="Left"/>
|
||||
<TextBlock Text="크기(pt)" Margin="14,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="SizeBox" Width="50" Text="9" VerticalContentAlignment="Center"/>
|
||||
<CheckBox x:Name="BoldBox" Content="굵게" Margin="14,0,0,0" VerticalAlignment="Center"/>
|
||||
<CheckBox x:Name="ItalicBox" Content="기울임" Margin="10,0,0,0" VerticalAlignment="Center"/>
|
||||
<CheckBox x:Name="UnderlineBox" Content="밑줄" Margin="10,0,0,0" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,10,0,0">
|
||||
<Button DockPanel.Dock="Right" Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
<Button DockPanel.Dock="Right" Content="전체에 적용" Padding="14,4" Margin="8,0,0,0" Click="OnApplyAll"/>
|
||||
<Button DockPanel.Dock="Right" Content="선택 조합에 적용" Padding="14,4" Click="OnApplySelected"/>
|
||||
<TextBlock x:Name="ResultText" VerticalAlignment="Center" Foreground="{DynamicResource B.Success}"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 집계 목록 -->
|
||||
<ListBox x:Name="GroupList" SelectionMode="Extended" FontSize="13"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Display}" Margin="2"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 폰트 일괄 변경 대화상자 — 레거시 frmFontManager 이식.
|
||||
/// 문서 전체 (타입·글꼴·크기·스타일) 집계 → 선택 조합 또는 전체에 새 글꼴 일괄 적용(Undo 1스텝).
|
||||
/// </summary>
|
||||
public partial class FontManagerDialogView : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly DesignerViewModel designer;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public FontManagerDialogView(DesignerViewModel designer)
|
||||
{
|
||||
this.designer = designer;
|
||||
InitializeComponent();
|
||||
FamilyBox.ItemsSource = Fonts.SystemFontFamilies
|
||||
.Select(f => f.FamilyNames.Values.FirstOrDefault() ?? f.Source)
|
||||
.Distinct()
|
||||
.OrderBy(name => name, StringComparer.CurrentCulture)
|
||||
.ToList();
|
||||
FamilyBox.Text = "굴림";
|
||||
Reload();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void Reload()
|
||||
{
|
||||
GroupList.ItemsSource = designer.CollectFontUsage();
|
||||
}
|
||||
|
||||
private void OnApplySelected(object sender, RoutedEventArgs e)
|
||||
=> Apply(GroupList.SelectedItems.Cast<FontUsageGroup>().ToList());
|
||||
|
||||
private void OnApplyAll(object sender, RoutedEventArgs e)
|
||||
=> Apply(GroupList.Items.Cast<FontUsageGroup>().ToList());
|
||||
|
||||
private void Apply(List<FontUsageGroup> targets)
|
||||
{
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
MessageBox.Show("변경할 조합을 선택하세요.", "폰트 일괄 변경");
|
||||
return;
|
||||
}
|
||||
var family = FamilyBox.Text.Trim();
|
||||
if (family.Length == 0)
|
||||
{
|
||||
MessageBox.Show("글꼴명을 입력하세요.", "폰트 일괄 변경");
|
||||
return;
|
||||
}
|
||||
if (!double.TryParse(SizeBox.Text.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var sizePt)
|
||||
|| sizePt <= 0 || sizePt > 200)
|
||||
{
|
||||
MessageBox.Show("크기(pt)를 올바르게 입력하세요.", "폰트 일괄 변경");
|
||||
return;
|
||||
}
|
||||
|
||||
var newFont = new LegacyFont
|
||||
{
|
||||
Family = family,
|
||||
SizePt = sizePt,
|
||||
Bold = BoldBox.IsChecked == true,
|
||||
Italic = ItalicBox.IsChecked == true,
|
||||
Underline = UnderlineBox.IsChecked == true,
|
||||
};
|
||||
var changed = designer.ApplyFontBulk(targets, newFont);
|
||||
ResultText.Text = $"{changed}개 컨트롤 변경됨";
|
||||
Reload();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<UserControl x:Class="SheetMe.Designer.Views.InspectorView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ins="clr-namespace:SheetMe.Designer.ViewModels.Inspector"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance ins:InspectorViewModel}">
|
||||
|
||||
<UserControl.Resources>
|
||||
<!-- 행 VM 타입별 암시적 템플릿 -->
|
||||
<Style x:Key="RowLabel" TargetType="TextBlock">
|
||||
<Setter Property="Width" Value="86"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
</Style>
|
||||
<Style x:Key="RowEditor" TargetType="Control">
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
</Style>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:SectionRowViewModel}">
|
||||
<TextBlock Text="{Binding Label}" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"
|
||||
Margin="0,10,0,4" FontSize="12"/>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:TextRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:MultilineTextRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,0,0"/>
|
||||
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"
|
||||
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="48" MaxHeight="120"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:NumberRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ToggleRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<CheckBox IsChecked="{Binding IsOn}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ChoiceRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<ComboBox ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}" FontSize="12"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:TagPickerRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<Button DockPanel.Dock="Right" Content="…" Width="24" Margin="4,0,0,0"
|
||||
Command="{Binding BrowseCommand}" ToolTip="목록에서 선택"/>
|
||||
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"
|
||||
ToolTip="직접 입력 또는 … 버튼으로 선택"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<TextBlock Text="{Binding ValueText}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ToggleAdvancedRowViewModel}">
|
||||
<Button Content="{Binding ButtonText}" Command="{Binding ToggleCommand}"
|
||||
Margin="0,6,0,2" Padding="6,3" HorizontalAlignment="Stretch"
|
||||
Background="{DynamicResource B.Chip}" BorderBrush="{DynamicResource B.Line2}" FontSize="11" Foreground="{DynamicResource B.Muted}"/>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:AddPropertyRowViewModel}">
|
||||
<DockPanel Margin="0,6,0,2">
|
||||
<Button DockPanel.Dock="Right" Content="추가" Padding="8,2" Margin="4,0,0,0"
|
||||
Command="{Binding AddCommand}"
|
||||
ToolTip="레거시 컨트롤의 속성 이름을 정확히 입력하세요 (예: EnterTabYon, AutoHeight)"/>
|
||||
<TextBox Text="{Binding KeyText, UpdateSourceTrigger=PropertyChanged}" FontSize="12"
|
||||
ToolTip="새 속성 키 입력"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:QueryRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<Button DockPanel.Dock="Right" Content="편집…" Padding="6,1" Margin="4,0,0,0"
|
||||
Command="{Binding EditCommand}" ToolTip="쿼리 편집기 열기 (데이터소스 더블클릭과 동일)"/>
|
||||
<TextBlock Text="{Binding Summary}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
ToolTip="{Binding ValueText}"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ColorRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<!-- 스와치 = 피커 버튼(클릭 → 색상 선택 대화상자), 직접 입력도 병행 -->
|
||||
<Button DockPanel.Dock="Right" Width="26" Height="22" Margin="4,0,0,0" Padding="0"
|
||||
Command="{Binding PickCommand}" Cursor="Hand"
|
||||
ToolTip="클릭하여 색상 선택">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="{Binding Preview}" BorderBrush="{DynamicResource B.Line2}"
|
||||
BorderThickness="1" CornerRadius="4" SnapsToDevicePixels="True"/>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
</Button>
|
||||
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"
|
||||
ToolTip="R, G, B 또는 색 이름 (예: 224, 224, 224 / White) — 오른쪽 스와치 클릭 시 피커"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</UserControl.Resources>
|
||||
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="{Binding Summary}" FontWeight="Bold"
|
||||
Margin="10,10,10,2" Foreground="{DynamicResource B.Muted}" TextTrimming="CharacterEllipsis"/>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="10,0,10,10"/>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.</summary>
|
||||
public partial class InspectorView : UserControl
|
||||
{
|
||||
public InspectorView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.MainView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
||||
xmlns:v="clr-namespace:SheetMe.Designer.Views"
|
||||
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
|
||||
xmlns:bh="clr-namespace:SheetMe.Designer.Behaviors"
|
||||
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance vm:MainViewModel}"
|
||||
Title="{Binding Title}"
|
||||
Icon="pack://application:,,,/SheetMe.Designer;component/Assets/sheetme.ico"
|
||||
Width="1440" Height="920" MinWidth="940" MinHeight="640"
|
||||
WindowStyle="None" WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource B.AppBg}" FontFamily="Malgun Gothic" FontSize="13"
|
||||
Loaded="OnWindowLoaded" StateChanged="OnWindowStateChanged">
|
||||
|
||||
<!-- GlassFrameThickness 0,0,0,1: DWM 창 그림자 활성화([200]SheetMe 크롬과 동일) -->
|
||||
<shell:WindowChrome.WindowChrome>
|
||||
<shell:WindowChrome CaptionHeight="40" ResizeBorderThickness="6" GlassFrameThickness="0,0,0,1"
|
||||
CornerRadius="0" UseAeroCaptionButtons="False"/>
|
||||
</shell:WindowChrome.WindowChrome>
|
||||
|
||||
<Window.DataContext>
|
||||
<vm:MainViewModel/>
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Resources>
|
||||
<ctl:TypeToIconConverter x:Key="TypeToIcon"/>
|
||||
<ctl:TypeToCategoryConverter x:Key="TypeToCategory"/>
|
||||
<ctl:IconNameToVisualConverter x:Key="IconName"/>
|
||||
<ctl:InverseBoolConverter x:Key="InverseBool"/>
|
||||
|
||||
<!-- 팔레트(도구 상자/플라이아웃 공용) — 카테고리 그룹 뷰 -->
|
||||
<CollectionViewSource x:Key="PaletteGrouped" Source="{Binding PaletteItems}">
|
||||
<CollectionViewSource.GroupDescriptions>
|
||||
<PropertyGroupDescription PropertyName="Type" Converter="{StaticResource TypeToCategory}"/>
|
||||
</CollectionViewSource.GroupDescriptions>
|
||||
</CollectionViewSource>
|
||||
|
||||
<!-- 도구 상자 타일 카드([200] 컴포넌트 카드 룩) — 선택 하이라이트 없음 -->
|
||||
<Style x:Key="PaletteTile" TargetType="ListBoxItem">
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="bd" Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}"
|
||||
BorderThickness="1" CornerRadius="5" Margin="3" SnapsToDevicePixels="True">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 팔레트 카테고리 그룹 헤더 -->
|
||||
<Style x:Key="PaletteGroupHeader" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="10.5"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Setter Property="Margin" Value="5,9,0,4"/>
|
||||
</Style>
|
||||
|
||||
<!-- 플라이아웃/줌 팝업 행 버튼 — 왼정렬 hover 행 -->
|
||||
<Style x:Key="FlyoutRow" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Padding" Value="12,7"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" CornerRadius="6"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel>
|
||||
<!-- ===== 커스텀 타이틀바: 로고 + 메뉴 | 중앙 제목 | 창 제어 ===== -->
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource B.Titlebar}" Height="40"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 좌: 로고 + 메뉴 -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||
<Image Source="pack://application:,,,/SheetMe.Designer;component/Assets/sheetme-logo.png"
|
||||
Width="20" Height="20" VerticalAlignment="Center" RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
<Menu VerticalAlignment="Center" Margin="8,0,0,0">
|
||||
<MenuItem Header="파일(_F)">
|
||||
<MenuItem Header="새 서식(_N)" Command="{Binding NewFileCommand}" InputGestureText="Ctrl+N"/>
|
||||
<MenuItem Header="열기(_O)..." Command="{Binding OpenFileCommand}" InputGestureText="Ctrl+O"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="저장(_S)" Command="{Binding SaveFileCommand}" InputGestureText="Ctrl+S"/>
|
||||
<MenuItem Header="다른 이름으로 저장(_A)..." Command="{Binding SaveAsFileCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="DB에서 열기(_D)..." Command="{Binding OpenFromDbCommand}"/>
|
||||
<MenuItem Header="DB에 저장(_B)..." Command="{Binding SaveToDbCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="JSON 가져오기(_I)..." Command="{Binding ImportJsonCommand}"/>
|
||||
<MenuItem Header="JSON 내보내기(_E)..." Command="{Binding ExportJsonCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="미리보기(_V)..." Command="{Binding PreviewCommand}"/>
|
||||
<MenuItem Header="인쇄(_P)..." Command="{Binding PrintCommand}" InputGestureText="Ctrl+P"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="종료(_X)" Command="{Binding ExitCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="편집(_E)">
|
||||
<MenuItem Header="실행 취소(_U)" Command="{Binding CurrentDesigner.UndoCommand}" InputGestureText="Ctrl+Z"/>
|
||||
<MenuItem Header="다시 실행(_R)" Command="{Binding CurrentDesigner.RedoCommand}" InputGestureText="Ctrl+Y"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="잘라내기(_T)" Command="{Binding CurrentDesigner.CutCommand}" InputGestureText="Ctrl+X"/>
|
||||
<MenuItem Header="복사(_C)" Command="{Binding CurrentDesigner.CopyCommand}" InputGestureText="Ctrl+C"/>
|
||||
<MenuItem Header="붙여넣기(_P)" Command="{Binding CurrentDesigner.PasteCommand}" InputGestureText="Ctrl+V"/>
|
||||
<MenuItem Header="복제(_D)" Command="{Binding CurrentDesigner.DuplicateCommand}" InputGestureText="Ctrl+D"/>
|
||||
<MenuItem Header="삭제(_L)" Command="{Binding CurrentDesigner.DeleteCommand}" InputGestureText="Del"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="전체 선택(_A)" Command="{Binding CurrentDesigner.SelectAllCommand}" InputGestureText="Ctrl+A"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="배치(_A)">
|
||||
<MenuItem Header="맨 앞으로(_F)" Command="{Binding CurrentDesigner.BringToFrontCommand}"/>
|
||||
<MenuItem Header="맨 뒤로(_B)" Command="{Binding CurrentDesigner.SendToBackCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="그룹(_G)" Command="{Binding CurrentDesigner.GroupCommand}" InputGestureText="Ctrl+G"/>
|
||||
<MenuItem Header="그룹 해제(_U)" Command="{Binding CurrentDesigner.UngroupCommand}" InputGestureText="Ctrl+Shift+G"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="왼쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Left"/>
|
||||
<MenuItem Header="오른쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Right"/>
|
||||
<MenuItem Header="위 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Top"/>
|
||||
<MenuItem Header="아래 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Bottom"/>
|
||||
<MenuItem Header="가로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterH"/>
|
||||
<MenuItem Header="세로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterV"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="같은 크기로">
|
||||
<MenuItem Header="너비/높이 모두(_B)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Both"/>
|
||||
<MenuItem Header="너비(_W)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Width"/>
|
||||
<MenuItem Header="높이(_H)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Height"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="가로 간격">
|
||||
<MenuItem Header="균등(_E)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizEqual"/>
|
||||
<MenuItem Header="넓게(_I)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizIncrease"/>
|
||||
<MenuItem Header="좁게(_D)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizDecrease"/>
|
||||
<MenuItem Header="붙이기(_C)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizConcat"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="세로 간격">
|
||||
<MenuItem Header="균등(_E)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertEqual"/>
|
||||
<MenuItem Header="넓게(_I)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertIncrease"/>
|
||||
<MenuItem Header="좁게(_D)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertDecrease"/>
|
||||
<MenuItem Header="붙이기(_C)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertConcat"/>
|
||||
</MenuItem>
|
||||
<Separator/>
|
||||
<MenuItem Header="페이지 가로 가운데(_H)" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="H"/>
|
||||
<MenuItem Header="페이지 세로 가운데(_V)" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="V"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="{Binding CurrentDesigner.TabOrderMenuHeader, FallbackValue=탭순서 편집 시작}"
|
||||
Command="{Binding CurrentDesigner.TabOrderCommand}"
|
||||
IsChecked="{Binding CurrentDesigner.IsTabOrderMode, Mode=OneWay}"
|
||||
ToolTip="입력 컨트롤을 원하는 입력 순서대로 클릭한 뒤 완료 — Esc 취소"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="보기(_V)">
|
||||
<MenuItem Header="확대(_I)" Command="{Binding ZoomInCommand}" InputGestureText="Ctrl+휠↑"/>
|
||||
<MenuItem Header="축소(_O)" Command="{Binding ZoomOutCommand}" InputGestureText="Ctrl+휠↓"/>
|
||||
<MenuItem Header="100%(_R)" Command="{Binding ZoomResetCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem x:Name="ThemeMenuItem" Header="다크 테마(_D)" IsCheckable="True" Click="OnThemeToggleClick"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="도구(_T)">
|
||||
<MenuItem Header="서식 수정이력(_H)..." Command="{Binding SheetHistoryCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="상용구 관리(_W)..." Command="{Binding RecordWordCommand}"/>
|
||||
<MenuItem Header="폰트 일괄 변경(_F)..." Command="{Binding FontManagerCommand}"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 중앙: 창 제목 -->
|
||||
<TextBlock Grid.Column="1" Text="{Binding Title}" Foreground="{DynamicResource B.Muted}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="12"/>
|
||||
|
||||
<!-- 우: 창 제어 -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||
<Button Style="{StaticResource CaptionBtn}" Click="OnMinimizeClick" ToolTip="최소화">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
|
||||
</Button>
|
||||
<Button Style="{StaticResource CaptionBtn}" Click="OnMaxRestoreClick" ToolTip="최대화/복원">
|
||||
<TextBlock x:Name="MaxGlyph" Text="" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
|
||||
</Button>
|
||||
<Button Style="{StaticResource CloseBtn}" Click="OnCloseWinClick" ToolTip="닫기">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ===== 하단 상태바 (줌은 플로팅 바로 이동) ===== -->
|
||||
<Border DockPanel.Dock="Bottom" Background="{DynamicResource B.Titlebar}"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,1,0,0" Padding="12,6">
|
||||
<TextBlock Text="{Binding StatusText}" Foreground="{DynamicResource B.Muted}" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
|
||||
<!-- ===== 본문 3열: 좌 탭 패널 | 문서 탭+캔버스+플로팅 바 | 속성 ([200] 레이아웃) ===== -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="232" MinWidth="180" MaxWidth="480"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="280" MinWidth="220" MaxWidth="560"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 좌: 알약 탭 — 서식 목록 / 레이어 / 도구 상자 -->
|
||||
<Border Grid.Column="0" Background="{DynamicResource B.Panel}">
|
||||
<TabControl x:Name="LeftTabs" SelectedIndex="{Binding SelectedLeftTabIndex, Mode=TwoWay}">
|
||||
|
||||
<!-- 서식 목록 -->
|
||||
<TabItem>
|
||||
<TabItem.Header><TextBlock Text="서식 목록" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
|
||||
<DockPanel>
|
||||
<DockPanel DockPanel.Dock="Top" Margin="8,8,8,4">
|
||||
<Button DockPanel.Dock="Right" Content="검색" Margin="6,0,0,0"
|
||||
Command="{Binding SearchSheetsCommand}"/>
|
||||
<TextBox Text="{Binding SheetSearchKeyword, UpdateSourceTrigger=PropertyChanged}"
|
||||
ToolTip="서식명/코드 검색 (Enter)">
|
||||
<TextBox.InputBindings>
|
||||
<KeyBinding Key="Enter" Command="{Binding SearchSheetsCommand}"/>
|
||||
</TextBox.InputBindings>
|
||||
</TextBox>
|
||||
</DockPanel>
|
||||
<Grid>
|
||||
<ListBox x:Name="SheetListBox" ItemsSource="{Binding SheetList}"
|
||||
Margin="4,0,4,4"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
MouseDoubleClick="OnSheetListDoubleClick"
|
||||
ToolTip="더블클릭으로 열기 — 여러 서식을 탭으로 동시에 편집할 수 있습니다">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="1">
|
||||
<TextBlock Text="{Binding ShtCod}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
||||
Width="52" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasDesign}" Value="False">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Setter Property="Opacity" Value="0.6"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<TextBlock Text="불러오는 중..." HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
Visibility="{Binding IsSheetListLoading, Converter={StaticResource BoolToVisibility}}"/>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
<!-- 레이어 (+ 페이지) -->
|
||||
<TabItem>
|
||||
<TabItem.Header><TextBlock Text="레이어" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
|
||||
<DockPanel>
|
||||
<!-- 페이지 섹션 -->
|
||||
<DockPanel DockPanel.Dock="Top" Margin="0,4,0,0">
|
||||
<TextBlock DockPanel.Dock="Top" Text="페이지" FontWeight="Bold" FontSize="11"
|
||||
Margin="10,4,10,4" Foreground="{DynamicResource B.Muted}"/>
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="10,2,10,6">
|
||||
<Button Content="추가" Command="{Binding CurrentDesigner.AddPageCommand}"/>
|
||||
<Button Content="삭제" Margin="6,0,0,0" Command="{Binding CurrentDesigner.RemovePageCommand}"/>
|
||||
</StackPanel>
|
||||
<ListBox ItemsSource="{Binding CurrentDesigner.Pages}"
|
||||
SelectedItem="{Binding CurrentDesigner.SelectedPage}"
|
||||
MaxHeight="110" Margin="4,0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="2,1">
|
||||
<TextBlock Text="{Binding Index, StringFormat=페이지 {0}}" FontSize="12"/>
|
||||
<TextBlock Text="{Binding SizeText, StringFormat= ({0})}" FontSize="11" Foreground="{DynamicResource B.Muted}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
<Border DockPanel.Dock="Top" Height="1" Background="{DynamicResource B.Line}" Margin="8,2"/>
|
||||
<!-- 레이어 목록 (활성 페이지, 그리기 순서) -->
|
||||
<ListBox ItemsSource="{Binding CurrentDesigner.SelectedPage.Controls}"
|
||||
SelectedItem="{Binding CurrentDesigner.SelectedLayerItem}"
|
||||
Margin="4,2,4,6"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0,1">
|
||||
<CheckBox DockPanel.Dock="Right" IsChecked="{Binding IsLockedFlag}"
|
||||
ToolTip="잠금" Margin="4,0,0,0"/>
|
||||
<CheckBox DockPanel.Dock="Right" IsChecked="{Binding IsHiddenFlag}"
|
||||
ToolTip="숨김" Margin="4,0,0,0"/>
|
||||
<TextBlock Text="{Binding Id}" FontSize="12" TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
<!-- 도구 상자 ([200] 타일 카드 룩 — 드래그/더블클릭 배치) -->
|
||||
<TabItem>
|
||||
<TabItem.Header><TextBlock Text="도구 상자" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
|
||||
<ListBox ItemsSource="{Binding Source={StaticResource PaletteGrouped}}"
|
||||
ItemContainerStyle="{StaticResource PaletteTile}"
|
||||
Margin="6,4,6,6"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
ToolTip="더블클릭 또는 드래그로 캔버스에 배치">
|
||||
<b:Interaction.Behaviors>
|
||||
<bh:PaletteDragBehavior/>
|
||||
</b:Interaction.Behaviors>
|
||||
<!-- 그룹 세로 나열(GroupStyle.Panel 기본) + 그룹 내 타일 2열(ItemsPanel — 그룹 내부 항목이 상속) -->
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="2"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.GroupStyle>
|
||||
<GroupStyle>
|
||||
<GroupStyle.HeaderTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}" Style="{StaticResource PaletteGroupHeader}"/>
|
||||
</DataTemplate>
|
||||
</GroupStyle.HeaderTemplate>
|
||||
</GroupStyle>
|
||||
</ListBox.GroupStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel HorizontalAlignment="Center" Margin="2,9,2,8" Background="Transparent">
|
||||
<ContentControl Content="{Binding Type, Converter={StaticResource TypeToIcon}}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,5" IsTabStop="False" Focusable="False"/>
|
||||
<TextBlock Text="{Binding DisplayName}" FontSize="11" TextAlignment="Center"
|
||||
HorizontalAlignment="Center" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Column="1" Style="{StaticResource ColSplitter}" ToolTip="좌측 패널 너비 조절"/>
|
||||
|
||||
<!-- 중앙: 문서 탭(크롬식) + 캔버스 + 플로팅 팔레트 바 -->
|
||||
<Grid Grid.Column="2" Background="{DynamicResource B.CanvasBg}">
|
||||
<TabControl Style="{StaticResource DocTabs}"
|
||||
ItemsSource="{Binding OpenDesigners}"
|
||||
SelectedItem="{Binding CurrentDesigner}"
|
||||
ItemContainerStyle="{StaticResource DocTabItem}">
|
||||
<TabControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<ContentControl Content="{Binding Source=file-text, Converter={StaticResource IconName}, ConverterParameter=12}"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center" IsTabStop="False" Focusable="False"/>
|
||||
<TextBlock Text="{Binding DisplayName}" MaxWidth="170" Style="{StaticResource TabHeaderText}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
<Button Content="✕" FontSize="9" Margin="7,0,0,0"
|
||||
Style="{StaticResource Subtle}" Padding="3,0" Cursor="Hand" ToolTip="문서 닫기"
|
||||
Command="{Binding DataContext.CloseDocumentCommand,
|
||||
RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</TabControl.ItemTemplate>
|
||||
<TabControl.ContentTemplate>
|
||||
<DataTemplate>
|
||||
<v:DesignerCanvasView/>
|
||||
</DataTemplate>
|
||||
</TabControl.ContentTemplate>
|
||||
</TabControl>
|
||||
|
||||
<!-- 플로팅 팔레트 바 (Figma 식 — 컨트롤 추가 플라이아웃 | 줌) -->
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
CornerRadius="12" Padding="7,5" HorizontalAlignment="Center" VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,20">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="20" ShadowDepth="3" Opacity="0.35" Color="Black"/>
|
||||
</Border.Effect>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<ToggleButton Width="38" Height="34" Padding="0" Focusable="False"
|
||||
ToolTip="선택 도구 — 클릭으로 선택, 드래그로 이동"
|
||||
IsChecked="{Binding IsHandTool, Converter={StaticResource InverseBool}, Mode=OneWay}"
|
||||
Click="OnSelectToolClick"
|
||||
Content="{Binding Source=mouse-pointer-click, Converter={StaticResource IconName}, ConverterParameter=18}"/>
|
||||
<ToggleButton Width="38" Height="34" Padding="0" Focusable="False"
|
||||
ToolTip="손 도구 — 드래그로 화면 이동 (Space 누르는 동안 임시)"
|
||||
IsChecked="{Binding IsHandTool, Mode=OneWay}"
|
||||
Click="OnHandToolClick"
|
||||
Content="{Binding Source=hand, Converter={StaticResource IconName}, ConverterParameter=18}"/>
|
||||
<Border Width="1" Height="22" Background="{DynamicResource B.Line}" Margin="5,0" VerticalAlignment="Center"/>
|
||||
<Button x:Name="AddControlBtn" Style="{StaticResource Subtle}" Width="38" Height="34" Padding="0"
|
||||
ToolTip="컨트롤 추가 — 활성 페이지 중앙에 배치" Click="OnAddControlFlyoutClick"
|
||||
Content="{Binding Source=layout-grid, Converter={StaticResource IconName}, ConverterParameter=18}"/>
|
||||
<Border Width="1" Height="22" Background="{DynamicResource B.Line}" Margin="5,0" VerticalAlignment="Center"/>
|
||||
<Button Style="{StaticResource Subtle}" Width="30" Height="34" Padding="0" ToolTip="축소 (Ctrl+휠↓)"
|
||||
Command="{Binding ZoomOutCommand}"
|
||||
Content="{Binding Source=zoom-out, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
<Button x:Name="ZoomBtn" Style="{StaticResource Subtle}" Height="34" Padding="8,0,6,0" ToolTip="줌"
|
||||
Click="OnZoomFlyoutClick">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding CurrentDesigner.ZoomPercentText, FallbackValue=100%}"
|
||||
FontSize="12" MinWidth="38" TextAlignment="Center" VerticalAlignment="Center"/>
|
||||
<ContentControl Content="{Binding Source=chevron-down, Converter={StaticResource IconName}, ConverterParameter=11}"
|
||||
Margin="3,1,0,0" VerticalAlignment="Center" IsTabStop="False" Focusable="False"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource Subtle}" Width="30" Height="34" Padding="0" ToolTip="확대 (Ctrl+휠↑)"
|
||||
Command="{Binding ZoomInCommand}"
|
||||
Content="{Binding Source=zoom-in, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 컨트롤 추가 플라이아웃 (카테고리별 타일) -->
|
||||
<Popup x:Name="AddControlPopup" PlacementTarget="{Binding ElementName=AddControlBtn}" Placement="Top"
|
||||
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade" VerticalOffset="-8">
|
||||
<Border Margin="18" Background="{DynamicResource B.Panel}" BorderBrush="{DynamicResource B.Line}"
|
||||
BorderThickness="1" CornerRadius="10" MinWidth="300">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="16" ShadowDepth="3" Opacity="0.4" Color="Black"/>
|
||||
</Border.Effect>
|
||||
<ScrollViewer MaxHeight="440" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Source={StaticResource PaletteGrouped}}" Margin="10,9,10,10">
|
||||
<!-- 그룹 세로 나열 + 그룹 내 타일 4열([200] 플라이아웃 치수) -->
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="4"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.GroupStyle>
|
||||
<GroupStyle>
|
||||
<GroupStyle.HeaderTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}" FontSize="10.5" FontWeight="Bold"
|
||||
Foreground="{DynamicResource B.Muted}" Margin="2,9,0,6"/>
|
||||
</DataTemplate>
|
||||
</GroupStyle.HeaderTemplate>
|
||||
</GroupStyle>
|
||||
</ItemsControl.GroupStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Width="66" Margin="3" Padding="4,9,4,8" Click="OnFlyoutTileClick"
|
||||
Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}"
|
||||
ToolTip="{Binding DisplayName}">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<ContentControl Content="{Binding Type, Converter={StaticResource TypeToIcon}}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,5" IsTabStop="False" Focusable="False"/>
|
||||
<TextBlock Text="{Binding DisplayName}" FontSize="11" TextAlignment="Center"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
|
||||
<!-- 줌 플라이아웃 -->
|
||||
<Popup x:Name="ZoomPopup" PlacementTarget="{Binding ElementName=ZoomBtn}" Placement="Top"
|
||||
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade" VerticalOffset="-8">
|
||||
<Border Margin="18" Background="{DynamicResource B.Panel}" BorderBrush="{DynamicResource B.Line}"
|
||||
BorderThickness="1" CornerRadius="10" MinWidth="190">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="16" ShadowDepth="3" Opacity="0.4" Color="Black"/>
|
||||
</Border.Effect>
|
||||
<StackPanel Margin="5">
|
||||
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomInCommand}" Click="OnZoomRowClick">
|
||||
<DockPanel LastChildFill="True">
|
||||
<TextBlock DockPanel.Dock="Right" Text="Ctrl+휠↑" Foreground="{DynamicResource B.Muted}"
|
||||
FontSize="11.5" Margin="24,0,0,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="확대" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomOutCommand}" Click="OnZoomRowClick">
|
||||
<DockPanel LastChildFill="True">
|
||||
<TextBlock DockPanel.Dock="Right" Text="Ctrl+휠↓" Foreground="{DynamicResource B.Muted}"
|
||||
FontSize="11.5" Margin="24,0,0,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="축소" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</Button>
|
||||
<Border Height="1" Background="{DynamicResource B.Line}" Margin="8,5"/>
|
||||
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomResetCommand}" Click="OnZoomRowClick">
|
||||
<TextBlock Text="100%" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
|
||||
<GridSplitter Grid.Column="3" Style="{StaticResource ColSplitter}" ToolTip="속성 패널 너비 조절"/>
|
||||
|
||||
<!-- 우: 속성 인스펙터 -->
|
||||
<Border Grid.Column="4" Background="{DynamicResource B.Panel}">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource B.PanelHeader}" Padding="11,8"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
|
||||
<TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/>
|
||||
</Border>
|
||||
<v:InspectorView DataContext="{Binding CurrentDesigner.Inspector}"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Data.Stores;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 메인 셸 윈도우 — 커스텀 타이틀바(로고+메뉴) | 좌(서식 목록/도구상자/페이지/레이어) | 문서 탭 | 인스펙터.
|
||||
/// [200]SheetMe 크롬(WindowChrome CaptionHeight 40) 이식 — 창 제어/테마 토글은 뷰 전용 관심사라 코드비하인드 처리.
|
||||
/// </summary>
|
||||
public partial class MainView : Window
|
||||
{
|
||||
public MainView()
|
||||
{
|
||||
InitializeComponent();
|
||||
// StaysOpen=False 팝업은 바깥 클릭(버튼 포함)으로 먼저 닫힌다 — 토글 버튼 재클릭이 곧바로 다시 열지 않게 닫힌 시각 기록
|
||||
AddControlPopup.Closed += (_, _) => addControlPopupClosedAt = Environment.TickCount;
|
||||
ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount;
|
||||
}
|
||||
|
||||
/// <summary>기동 — 테마 메뉴 체크 동기화 + 뷰모델 Loaded 커맨드 실행(구 CustomWindow.LoadedCommand 대체)</summary>
|
||||
private void OnWindowLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
|
||||
if (DataContext is MainViewModel viewModel && viewModel.LoadedCommand?.CanExecute(null) == true)
|
||||
{
|
||||
viewModel.LoadedCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서식 목록 더블클릭 → 탭으로 열기(뷰모델 위임 — ListBox 더블클릭은 InputBinding 미지원)</summary>
|
||||
private void OnSheetListDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel viewModel)
|
||||
{
|
||||
viewModel.OpenSheetFromList(SheetListBox.SelectedItem as SheetSummary);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnThemeToggleClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ThemeManager.Toggle();
|
||||
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
|
||||
}
|
||||
|
||||
/// <summary>플로팅 바 — 컨트롤 추가 플라이아웃 토글(닫힘 직후 재클릭 가드)</summary>
|
||||
private void OnAddControlFlyoutClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Environment.TickCount - addControlPopupClosedAt > 150)
|
||||
{
|
||||
AddControlPopup.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>플로팅 바 — 줌 플라이아웃 토글</summary>
|
||||
private void OnZoomFlyoutClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Environment.TickCount - zoomPopupClosedAt > 150)
|
||||
{
|
||||
ZoomPopup.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>플라이아웃 타일 클릭 — 활성 페이지 중앙에 컨트롤 배치(팔레트 더블클릭과 동일 경로)</summary>
|
||||
private void OnFlyoutTileClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
AddControlPopup.IsOpen = false;
|
||||
if ((sender as FrameworkElement)?.DataContext is SheetMe.Core.Catalog.ControlDescriptor item
|
||||
&& DataContext is MainViewModel viewModel)
|
||||
{
|
||||
viewModel.CurrentDesigner?.AddPaletteItemAtCenter(item.Type);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>줌 플라이아웃 행 클릭 — 커맨드 실행 후 팝업 닫기</summary>
|
||||
private void OnZoomRowClick(object sender, RoutedEventArgs e) => ZoomPopup.IsOpen = false;
|
||||
|
||||
/// <summary>플로팅 바 — 선택 도구(기본)</summary>
|
||||
private void OnSelectToolClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel viewModel)
|
||||
{
|
||||
viewModel.IsHandTool = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>플로팅 바 — 손(팬) 도구</summary>
|
||||
private void OnHandToolClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel viewModel)
|
||||
{
|
||||
viewModel.IsHandTool = true;
|
||||
}
|
||||
}
|
||||
|
||||
private int addControlPopupClosedAt;
|
||||
private int zoomPopupClosedAt;
|
||||
|
||||
private void OnMinimizeClick(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
|
||||
|
||||
private void OnMaxRestoreClick(object sender, RoutedEventArgs e) =>
|
||||
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
|
||||
|
||||
private void OnCloseWinClick(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
/// <summary>최대화/복원 글리프 전환(Segoe MDL2: E922=최대화, E923=복원)</summary>
|
||||
private void OnWindowStateChanged(object sender, EventArgs e) =>
|
||||
MaxGlyph.Text = WindowState == WindowState.Maximized ? "" : "";
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<UserControl x:Class="SheetMe.Designer.Views.PageView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance vm:PageViewModel}">
|
||||
|
||||
<!-- 종이 위 렌더는 레거시 충실 유지 — 앱 다크 테마의 암시 TextBlock 스타일(B.Ink 밝은 글자)이
|
||||
페이지 콘텐츠에 스미지 않게 여기서 기본(검정) 암시 스타일로 차단 -->
|
||||
<UserControl.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="Black"/>
|
||||
</Style>
|
||||
</UserControl.Resources>
|
||||
|
||||
<!-- 용지 1장: 그림자 + 종이 + 컨트롤 층. 텍스트 선명도는 Ideal 포맷팅 -->
|
||||
<Grid Width="{Binding WidthDip}" Height="{Binding HeightDip}"
|
||||
TextOptions.TextFormattingMode="Ideal">
|
||||
|
||||
<!-- 종이 그림자 (Effect 대신 오프셋 사각형 — 성능) -->
|
||||
<Border Margin="3,3,-3,-3" Background="#22000000"/>
|
||||
|
||||
<!-- 종이 -->
|
||||
<Border Background="{Binding PaperBrush}" BorderBrush="#D8DCE2" BorderThickness="1"/>
|
||||
|
||||
<!-- 컨트롤 층 (그리기 순서 = 컬렉션 순서, 마지막이 최상위) -->
|
||||
<ItemsControl ItemsSource="{Binding Controls}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
<Setter Property="Width" Value="{Binding Width}"/>
|
||||
<Setter Property="Height" Value="{Binding Height}"/>
|
||||
<Setter Property="Opacity" Value="{Binding DesignOpacity}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>용지 1장 렌더 뷰 — DataContext 는 PageViewModel.</summary>
|
||||
public partial class PageView : UserControl
|
||||
{
|
||||
public PageView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.PreviewWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="미리보기" Width="900" Height="1000"
|
||||
WindowStartupLocation="CenterOwner" Background="{DynamicResource B.CanvasBg}">
|
||||
<DockPanel>
|
||||
<ToolBarTray DockPanel.Dock="Top">
|
||||
<ToolBar>
|
||||
<Button Content="인쇄..." Padding="10,3" Click="OnPrint"/>
|
||||
<Separator/>
|
||||
<Button Content="-" Padding="8,3" Click="OnZoomOut"/>
|
||||
<TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/>
|
||||
<Button Content="+" Padding="8,3" Click="OnZoomIn"/>
|
||||
</ToolBar>
|
||||
</ToolBarTray>
|
||||
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center">
|
||||
<StackPanel.LayoutTransform>
|
||||
<ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/>
|
||||
</StackPanel.LayoutTransform>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>미리보기 창 — 편집 크롬 없는 페이지 렌더(인쇄와 동일 비주얼) + 줌/인쇄.</summary>
|
||||
public partial class PreviewWindow : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly DesignerViewModel designer;
|
||||
private double zoom = 1.0;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PreviewWindow(DesignerViewModel designer)
|
||||
{
|
||||
this.designer = designer;
|
||||
InitializeComponent();
|
||||
BuildPages();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void BuildPages()
|
||||
{
|
||||
PagesHost.Children.Clear();
|
||||
foreach (var page in designer.Pages)
|
||||
{
|
||||
var frame = new Border
|
||||
{
|
||||
Background = page.PaperBrush,
|
||||
BorderBrush = System.Windows.Media.Brushes.LightGray,
|
||||
BorderThickness = new Thickness(1),
|
||||
Margin = new Thickness(0, 0, 0, 20),
|
||||
Child = PrintService.BuildPageVisual(page),
|
||||
Width = page.WidthDip,
|
||||
Height = page.HeightDip,
|
||||
};
|
||||
PagesHost.Children.Add(frame);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPrint(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
PrintService.Print(designer, designer.Document.Title.Length > 0
|
||||
? designer.Document.Title
|
||||
: designer.Document.FormId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"인쇄 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnZoomIn(object sender, RoutedEventArgs e) => ApplyZoom(zoom * 1.15);
|
||||
|
||||
private void OnZoomOut(object sender, RoutedEventArgs e) => ApplyZoom(zoom / 1.15);
|
||||
|
||||
private void ApplyZoom(double value)
|
||||
{
|
||||
zoom = Math.Clamp(value, 0.25, 3.0);
|
||||
ZoomTransform.ScaleX = zoom;
|
||||
ZoomTransform.ScaleY = zoom;
|
||||
ZoomText.Text = $"{zoom * 100:0}%";
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.QueryEditorWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="쿼리 편집" Width="860" Height="560" MinWidth="640" MinHeight="400"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
|
||||
Text="작성 시점에 <<변수>> 가 실제 값으로 치환되어 실행됩니다. 우측 변수를 더블클릭하면 커서 위치에 삽입됩니다."/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
|
||||
<Button Content="확인" Padding="20,5" Click="OnConfirm"/>
|
||||
<Button Content="취소" Padding="20,5" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="230"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- SQL 편집 영역 -->
|
||||
<TextBox x:Name="SqlBox" Grid.Column="0"
|
||||
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
|
||||
AcceptsReturn="True" AcceptsTab="True"
|
||||
TextWrapping="NoWrap"
|
||||
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
|
||||
TextChanged="OnSqlChanged"/>
|
||||
|
||||
<GridSplitter Grid.Column="1" HorizontalAlignment="Stretch" Background="{DynamicResource B.Line}"/>
|
||||
|
||||
<!-- 치환 변수 목록 -->
|
||||
<DockPanel Grid.Column="2">
|
||||
<TextBlock DockPanel.Dock="Top" Text="치환 변수 (더블클릭 삽입)" FontWeight="Bold"
|
||||
Foreground="{DynamicResource B.Muted}" Margin="4,0,0,6"/>
|
||||
<ListBox x:Name="VariableList" FontSize="12" MouseDoubleClick="OnInsertVariable"
|
||||
FontFamily="Consolas, D2Coding, 굴림체"/>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 큰 SQL 편집 영역 + 치환 변수 삽입.
|
||||
/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery — <<PatientInfo/SheetInfo/WorkInfo.속성>> 리플렉션 치환.
|
||||
/// 변수 목록 출처: [021]SheetLoadOperatingInfo 의 bzPatientInfo/bzSheetInfo/bzWorkInfo 공개 속성(스칼라만 큐레이션, 2026-07-16).
|
||||
/// </summary>
|
||||
public partial class QueryEditorWindow : Window
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>치환 변수 목록 — 작성 시점 실제 값으로 치환됨</summary>
|
||||
private static readonly string[] Variables =
|
||||
{
|
||||
"<<PatientInfo.ChtNum>>",
|
||||
"<<PatientInfo.ComNum>>",
|
||||
"<<PatientInfo.PatTyp>>",
|
||||
"<<PatientInfo.OdrNum>>",
|
||||
"<<PatientInfo.OdrSeq>>",
|
||||
"<<SheetInfo.ShtCod>>",
|
||||
"<<SheetInfo.ShtNam>>",
|
||||
"<<SheetInfo.EmrKey>>",
|
||||
"<<SheetInfo.EmrGbn>>",
|
||||
"<<SheetInfo.AdpDtm>>",
|
||||
"<<SheetInfo.PatTyp>>",
|
||||
"<<SheetInfo.OdrNum>>",
|
||||
"<<SheetInfo.OdrSeq>>",
|
||||
"<<SheetInfo.TemKey>>",
|
||||
"<<WorkInfo.WrkUid>>",
|
||||
"<<WorkInfo.WrkNam>>",
|
||||
"<<WorkInfo.WrkDte>>",
|
||||
"<<WorkInfo.WrkDtm>>",
|
||||
"<<WorkInfo.AdpDep>>",
|
||||
"<<WorkInfo.AdpDtm>>",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>편집 결과 SQL — 확인 시 채워짐</summary>
|
||||
public string QueryText { get; private set; } = string.Empty;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public QueryEditorWindow(string ownerLabel, string initialQuery)
|
||||
{
|
||||
InitializeComponent();
|
||||
Title = $"쿼리 편집 — {ownerLabel}";
|
||||
SqlBox.Text = initialQuery;
|
||||
VariableList.ItemsSource = Variables;
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
SqlBox.Focus();
|
||||
SqlBox.CaretIndex = SqlBox.Text.Length;
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnSqlChanged(object sender, TextChangedEventArgs e)
|
||||
=> LengthText.Text = $"{SqlBox.Text.Length:N0}자";
|
||||
|
||||
private void OnInsertVariable(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (VariableList.SelectedItem is not string variable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var caret = SqlBox.CaretIndex;
|
||||
SqlBox.Text = SqlBox.Text.Insert(caret, variable);
|
||||
SqlBox.CaretIndex = caret + variable.Length;
|
||||
SqlBox.Focus();
|
||||
}
|
||||
|
||||
private void OnConfirm(object sender, RoutedEventArgs e)
|
||||
{
|
||||
QueryText = SqlBox.Text;
|
||||
DialogResult = true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.RecordWordDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="상용구 관리" Width="560" Height="520" MinWidth="480" MinHeight="380"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
|
||||
<!-- 입력/버튼 -->
|
||||
<DockPanel DockPanel.Dock="Bottom" Margin="0,8,0,0">
|
||||
<Button DockPanel.Dock="Right" Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
<Button DockPanel.Dock="Right" Content="추가" Padding="16,4" Click="OnAdd" IsDefault="True"/>
|
||||
<TextBox x:Name="NewWordBox" Padding="4,3" Margin="0,0,8,0"
|
||||
VerticalContentAlignment="Center"
|
||||
ToolTip="새 상용구 입력 후 Enter 또는 추가"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 목록 + 우측 조작 -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<ListBox x:Name="WordList" Grid.Column="0" FontSize="13"
|
||||
MouseDoubleClick="OnEdit"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Value}" TextWrapping="Wrap" Margin="2"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="8,0,0,0">
|
||||
<Button Content="수정" Padding="10,3" Click="OnEdit"/>
|
||||
<Button Content="삭제" Padding="10,3" Margin="0,6,0,0" Click="OnRemove"/>
|
||||
<Separator Margin="0,10"/>
|
||||
<Button Content="위로 ▲" Padding="10,3" Click="OnMoveUp"/>
|
||||
<Button Content="아래로 ▼" Padding="10,3" Margin="0,6,0,0" Click="OnMoveDown"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 서식별 상용구 관리 대화상자 — E_SHTWRDMST 1단계 문구 추가/수정/삭제/순서.
|
||||
/// 레거시 런타임 상용구 팝업(MRecordWord)이 그대로 읽어가는 데이터를 관리한다.
|
||||
/// </summary>
|
||||
public partial class RecordWordDialogView : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly RecordWordStore store;
|
||||
private readonly string shtCod;
|
||||
private readonly ObservableCollection<RecordWordInfo> words = new();
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public RecordWordDialogView(RecordWordStore store, string shtCod, string sheetTitle)
|
||||
{
|
||||
this.store = store;
|
||||
this.shtCod = shtCod;
|
||||
InitializeComponent();
|
||||
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 작성 화면의 상용구 팝업에 표시되는 문구입니다. " +
|
||||
"(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)";
|
||||
WordList.ItemsSource = words;
|
||||
Loaded += (_, _) => Reload();
|
||||
NewWordBox.Loaded += (_, _) => NewWordBox.Focus();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void Reload()
|
||||
{
|
||||
try
|
||||
{
|
||||
words.Clear();
|
||||
foreach (var word in store.List(shtCod))
|
||||
{
|
||||
words.Add(word);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"상용구 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAdd(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var value = NewWordBox.Text.Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
store.Add(shtCod, value, Environment.UserName);
|
||||
NewWordBox.Clear();
|
||||
NewWordBox.Focus();
|
||||
Reload();
|
||||
WordList.SelectedIndex = words.Count - 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"추가 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEdit(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (WordList.SelectedItem is not RecordWordInfo selected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var editor = new QueryEditorWindow($"상용구 수정", selected.Value) { Owner = this };
|
||||
// QueryEditorWindow 재사용(큰 편집 영역) — 변수 삽입은 무시해도 무해
|
||||
if (editor.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var newValue = editor.QueryText.Trim();
|
||||
if (newValue.Length == 0 || newValue == selected.Value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
store.Update(selected.Key, newValue, Environment.UserName);
|
||||
Reload();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"수정 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRemove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (WordList.SelectedItem is not RecordWordInfo selected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (MessageBox.Show($"삭제할까요?\n\n{Truncate(selected.Value)}", "상용구 삭제",
|
||||
MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
store.Remove(selected.Key);
|
||||
Reload();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"삭제 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMoveUp(object sender, RoutedEventArgs e) => Move(-1);
|
||||
|
||||
private void OnMoveDown(object sender, RoutedEventArgs e) => Move(+1);
|
||||
|
||||
private void Move(int delta)
|
||||
{
|
||||
var index = WordList.SelectedIndex;
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= words.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
words.Move(index, target);
|
||||
store.Reorder(words.Select(w => w.Key).ToList(), Environment.UserName);
|
||||
WordList.SelectedIndex = target;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"순서 변경 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
Reload();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value)
|
||||
=> value.Length > 80 ? value[..80] + "…" : value;
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.RegisterSheetDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="신규 서식 등록" Width="420" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" ResizeMode="NoResize">
|
||||
<StackPanel Margin="16">
|
||||
<TextBlock Text="E_ShtMst 에 등록되지 않은 서식입니다. 신규 등록 후 저장합니다."
|
||||
TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,12"/>
|
||||
|
||||
<DockPanel Margin="0,3">
|
||||
<TextBlock Text="서식 코드" Width="80" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="CodeBox" MaxLength="10" Padding="4,3"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,3">
|
||||
<TextBlock Text="서식 명칭" Width="80" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="NameBox" MaxLength="100" Padding="4,3"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,3">
|
||||
<TextBlock Text="분류 코드" Width="80" VerticalAlignment="Center"/>
|
||||
<TextBox x:Name="ClassBox" MaxLength="5" Padding="4,3"
|
||||
ToolTip="선택 입력 — 예: A(초진), J(진단서류) 등 병원 분류 체계"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="기본값: 디자인 서식(ShtTyp='D'), 사용 'Y', 서식생성기 사용 'Y' — 상세 속성은 기록지정보 마스터에서 설정"
|
||||
TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" FontSize="11" Margin="0,10,0,0"/>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
|
||||
<Button Content="등록 후 저장" Padding="14,5" Click="OnConfirm" IsDefault="True"/>
|
||||
<Button Content="취소" Padding="14,5" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>신규 서식(E_ShtMst) 최소 등록 대화상자 — 코드/명칭/분류 입력.</summary>
|
||||
public partial class RegisterSheetDialogView : Window
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>서식 코드</summary>
|
||||
public string SheetCode => CodeBox.Text.Trim();
|
||||
|
||||
/// <summary>서식 명칭</summary>
|
||||
public string SheetName => NameBox.Text.Trim();
|
||||
|
||||
/// <summary>분류 코드(선택)</summary>
|
||||
public string? ClassCode => ClassBox.Text.Trim().Length == 0 ? null : ClassBox.Text.Trim();
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public RegisterSheetDialogView(string initialCode, string initialName)
|
||||
{
|
||||
InitializeComponent();
|
||||
CodeBox.Text = initialCode;
|
||||
NameBox.Text = initialName;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnConfirm(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SheetCode.Length == 0 || SheetName.Length == 0)
|
||||
{
|
||||
MessageBox.Show("서식 코드와 명칭을 입력하세요.", "신규 서식 등록");
|
||||
return;
|
||||
}
|
||||
DialogResult = true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<UserControl x:Class="SheetMe.Designer.Views.SelectionOverlayView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance vm:SelectionOverlayViewModel}"
|
||||
IsHitTestVisible="False">
|
||||
|
||||
<!-- 선택/마퀴/가이드 표시 전용 오버레이 — 입력은 월드 파이프라인이 처리(기하 히트테스트) -->
|
||||
<Canvas>
|
||||
|
||||
<!-- 정렬 가이드선 -->
|
||||
<ItemsControl ItemsSource="{Binding Guides}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Line Stroke="#FF4FA3" StrokeThickness="1" StrokeDashArray="4 3">
|
||||
<Line.Style>
|
||||
<Style TargetType="Line">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsVertical}" Value="True">
|
||||
<Setter Property="X1" Value="{Binding Position}"/>
|
||||
<Setter Property="X2" Value="{Binding Position}"/>
|
||||
<Setter Property="Y1" Value="-100000"/>
|
||||
<Setter Property="Y2" Value="100000"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsVertical}" Value="False">
|
||||
<Setter Property="Y1" Value="{Binding Position}"/>
|
||||
<Setter Property="Y2" Value="{Binding Position}"/>
|
||||
<Setter Property="X1" Value="-100000"/>
|
||||
<Setter Property="X2" Value="100000"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Line.Style>
|
||||
</Line>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 선택 박스 -->
|
||||
<Rectangle Canvas.Left="{Binding SelX}" Canvas.Top="{Binding SelY}"
|
||||
Width="{Binding SelW}" Height="{Binding SelH}"
|
||||
Stroke="#1E7BE8" StrokeThickness="1"
|
||||
Visibility="{Binding HasSelection, Converter={StaticResource BoolToVisibility}}"/>
|
||||
|
||||
<!-- 리사이즈 핸들 8개 -->
|
||||
<ItemsControl ItemsSource="{Binding Handles}"
|
||||
Visibility="{Binding ShowHandles, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Rectangle Width="8" Height="8" Fill="White" Stroke="#1E7BE8" StrokeThickness="1"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- 마퀴(러버밴드) -->
|
||||
<Rectangle Canvas.Left="{Binding MarX}" Canvas.Top="{Binding MarY}"
|
||||
Width="{Binding MarW}" Height="{Binding MarH}"
|
||||
Stroke="#1E7BE8" StrokeThickness="1" StrokeDashArray="3 2" Fill="#181E7BE8"
|
||||
Visibility="{Binding HasMarquee, Converter={StaticResource BoolToVisibility}}"/>
|
||||
|
||||
<!-- 탭순서 배지 (편집 모드 전용) -->
|
||||
<ItemsControl ItemsSource="{Binding TabBadges}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border MinWidth="18" Height="18" CornerRadius="9" Padding="4,0"
|
||||
BorderThickness="1.5" BorderBrush="White">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="#9AA6B4"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsAssigned}" Value="True">
|
||||
<Setter Property="Background" Value="#1E7BE8"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<TextBlock Text="{Binding Text}" Foreground="White" FontSize="10" FontWeight="Bold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Canvas>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>선택/마퀴/가이드 오버레이 — 표시 전용(입력 처리 없음).</summary>
|
||||
public partial class SelectionOverlayView : UserControl
|
||||
{
|
||||
public SelectionOverlayView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.SheetHistoryDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="서식 수정이력" Width="560" Height="520" MinWidth="480" MinHeight="380"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
|
||||
<Button Content="열람(새 탭)" Padding="16,4" Click="OnOpen" IsDefault="True"
|
||||
ToolTip="선택한 버전을 새 탭으로 엽니다 — 열람 후 'DB에 저장'하면 그 내용이 새 활성 버전이 됩니다(복원)"/>
|
||||
<Button Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<ListView x:Name="VersionList" MouseDoubleClick="OnOpen"
|
||||
VirtualizingPanel.IsVirtualizing="True">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="버전(SdgKey)" Width="110" DisplayMemberBinding="{Binding SdgKey}"/>
|
||||
<GridViewColumn Header="수정일시" Width="150" DisplayMemberBinding="{Binding UpdDtmText}"/>
|
||||
<GridViewColumn Header="수정자" Width="110" DisplayMemberBinding="{Binding UpdUid}"/>
|
||||
<GridViewColumn Header="상태" Width="80">
|
||||
<GridViewColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding StatusText}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding StatusText}" Value="활성">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Success}"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</DataTemplate>
|
||||
</GridViewColumn.CellTemplate>
|
||||
</GridViewColumn>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>이력 목록 표시용 행</summary>
|
||||
public sealed class VersionRow
|
||||
{
|
||||
/// <summary>디자인 버전 키</summary>
|
||||
public decimal SdgKey { get; init; }
|
||||
|
||||
/// <summary>수정일시 표시(yyyy-MM-dd HH:mm)</summary>
|
||||
public string UpdDtmText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>수정자</summary>
|
||||
public string UpdUid { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>상태 표시(활성/이력)</summary>
|
||||
public string StatusText { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서식 수정이력 대화상자 — E_SdgMst 버전 목록(레거시 UcSheetHistory 이식).
|
||||
/// 선택 버전을 새 탭으로 열람하고, 열람본을 'DB에 저장'하면 새 활성 버전이 되어 복원 흐름이 완성된다.
|
||||
/// </summary>
|
||||
public partial class SheetHistoryDialogView : Window
|
||||
{
|
||||
#region Properties
|
||||
/// <summary>선택된 버전 키 — 열람 확정 시 채워짐</summary>
|
||||
public decimal? SelectedSdgKey { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public SheetHistoryDialogView(string shtCod, string sheetTitle, List<DesignVersionInfo> versions)
|
||||
{
|
||||
InitializeComponent();
|
||||
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 저장할 때마다 이전 버전이 이력으로 보존됩니다. " +
|
||||
"이력 버전을 열람한 뒤 'DB에 저장'하면 해당 내용이 새 활성 버전이 됩니다(복원).";
|
||||
VersionList.ItemsSource = versions.Select(v => new VersionRow
|
||||
{
|
||||
SdgKey = v.SdgKey,
|
||||
UpdDtmText = FormatDtm(v.UpdDtm),
|
||||
UpdUid = v.UpdUid,
|
||||
StatusText = v.Deleted ? "이력" : "활성",
|
||||
}).ToList();
|
||||
CountText.Text = $"{versions.Count}개 버전";
|
||||
if (VersionList.Items.Count > 0)
|
||||
{
|
||||
VersionList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnOpen(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (VersionList.SelectedItem is not VersionRow row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SelectedSdgKey = row.SdgKey;
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
/// <summary>yyyyMMddHHmm(12) / yyyyMMddHHmmss(14) → 사람이 읽는 형식</summary>
|
||||
private static string FormatDtm(string dtm)
|
||||
{
|
||||
if (dtm.Length >= 12
|
||||
&& int.TryParse(dtm[..4], out _))
|
||||
{
|
||||
var time = $"{dtm[8..10]}:{dtm[10..12]}";
|
||||
return $"{dtm[..4]}-{dtm[4..6]}-{dtm[6..8]} {time}";
|
||||
}
|
||||
return dtm;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.SheetOpenDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="DB에서 서식 열기" Width="560" Height="520"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<DockPanel DockPanel.Dock="Top">
|
||||
<Button DockPanel.Dock="Right" Content="검색" Padding="14,4" Margin="6,0,0,0" Click="OnSearch" IsDefault="True"/>
|
||||
<TextBox x:Name="SearchBox" Padding="4,3" VerticalContentAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
|
||||
<Button Content="열기" Padding="18,5" Click="OnOpen"/>
|
||||
<Button Content="취소" Padding="18,5" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<ListView x:Name="SheetList" Margin="0,10,0,0" MouseDoubleClick="OnOpen">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="서식코드" Width="110" DisplayMemberBinding="{Binding ShtCod}"/>
|
||||
<GridViewColumn Header="서식명" Width="310" DisplayMemberBinding="{Binding Name}"/>
|
||||
<GridViewColumn Header="디자인" Width="70">
|
||||
<GridViewColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="있음" Foreground="{DynamicResource B.Success}"
|
||||
Visibility="{Binding HasDesign, Converter={StaticResource BoolToVisibility}}"/>
|
||||
</DataTemplate>
|
||||
</GridViewColumn.CellTemplate>
|
||||
</GridViewColumn>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// DB 서식 열기 대화상자 — 검색/목록/선택.
|
||||
/// 검색 실행 콜백을 주입받는 얇은 대화상자(모달 결과 = SelectedSheet).
|
||||
/// </summary>
|
||||
public partial class SheetOpenDialogView : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly Func<string?, List<SheetSummary>> search;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택된 서식 — 확인 시 채워짐</summary>
|
||||
public SheetSummary? SelectedSheet { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public SheetOpenDialogView(Func<string?, List<SheetSummary>> search)
|
||||
{
|
||||
this.search = search;
|
||||
InitializeComponent();
|
||||
Loaded += (_, _) => RunSearch();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnSearch(object sender, RoutedEventArgs e) => RunSearch();
|
||||
|
||||
private void RunSearch()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = search(SearchBox.Text);
|
||||
SheetList.ItemsSource = result;
|
||||
CountText.Text = $"{result.Count}건";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"서식 목록 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpen(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SheetList.SelectedItem is not SheetSummary selected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!selected.HasDesign)
|
||||
{
|
||||
MessageBox.Show("선택한 서식에는 저장된 디자인이 없습니다.", "열기",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
SelectedSheet = selected;
|
||||
DialogResult = true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.TagPickerDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="태그 선택" Width="480" Height="560"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock x:Name="TitleText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
|
||||
<TextBox x:Name="SearchBox" DockPanel.Dock="Top" Padding="4,3"
|
||||
TextChanged="OnSearchChanged" VerticalContentAlignment="Center"/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
|
||||
<Button Content="값 지우기" Padding="12,4" Click="OnClear"/>
|
||||
<Button Content="선택" Padding="18,4" Margin="8,0,0,0" Click="OnPick" IsDefault="True"/>
|
||||
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<ListBox x:Name="TagList" Margin="0,8,0,0" MouseDoubleClick="OnPick"
|
||||
VirtualizingPanel.IsVirtualizing="True" FontSize="13"/>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 태그 선택 대화상자 — 검색(부분 일치, 공백 구분 다중 토큰 AND) + 목록 선택.
|
||||
/// '값 지우기'는 빈 값으로 확정(속성 제거).
|
||||
/// </summary>
|
||||
public partial class TagPickerDialogView : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly IReadOnlyList<string> allTags;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택된 태그 — '값 지우기'면 빈 문자열</summary>
|
||||
public string? SelectedTag { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue)
|
||||
{
|
||||
allTags = tags;
|
||||
InitializeComponent();
|
||||
TitleText.Text = title;
|
||||
ApplyFilter(string.Empty);
|
||||
if (!string.IsNullOrEmpty(currentValue))
|
||||
{
|
||||
TagList.SelectedItem = tags.FirstOrDefault(t => t == currentValue);
|
||||
TagList.ScrollIntoView(TagList.SelectedItem);
|
||||
}
|
||||
Loaded += (_, _) => SearchBox.Focus();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnSearchChanged(object sender, TextChangedEventArgs e)
|
||||
=> ApplyFilter(SearchBox.Text);
|
||||
|
||||
private void ApplyFilter(string keyword)
|
||||
{
|
||||
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var filtered = tokens.Length == 0
|
||||
? allTags
|
||||
: allTags.Where(t => tokens.All(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
TagList.ItemsSource = filtered;
|
||||
CountText.Text = $"{filtered.Count}/{allTags.Count}건";
|
||||
if (filtered.Count > 0)
|
||||
{
|
||||
TagList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (TagList.SelectedItem is not string tag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SelectedTag = tag;
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void OnClear(object sender, RoutedEventArgs e)
|
||||
{
|
||||
SelectedTag = string.Empty;
|
||||
DialogResult = true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"His": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=__HOST__)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=__SERVICE__)));User Id=__USER__;Password=__PASSWORD__;"
|
||||
},
|
||||
"FormStore": {
|
||||
"SaveMode": "File",
|
||||
"XmlFolder": "forms"
|
||||
},
|
||||
"Designer": {
|
||||
"GridSize": 4,
|
||||
"SnapThreshold": 6
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FarPoint Spread XML 파서 테스트.
|
||||
/// 합성 샘플 기반(테스트 DB 의 실물 2건은 셀 없는 기본 골격) — 셀 있는 실서식 확보 시 재검증.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class FarPointSpreadParserTests
|
||||
{
|
||||
private const string SyntheticXml = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Spread class="M.EMR.UserControl.Spread" assembly="M.EMR.UserControl">
|
||||
<Presentation>
|
||||
<Sheets>
|
||||
<Sheet index="0">
|
||||
<AxisModels>
|
||||
<Row class="FarPoint.Win.Spread.Model.DefaultSheetAxisModel" defaultSize="24" orientation="Vertical" count="500">
|
||||
<Items>
|
||||
<Item index="0"><Size>40</Size></Item>
|
||||
<Item index="2"><Size>60</Size></Item>
|
||||
</Items>
|
||||
</Row>
|
||||
<Column class="FarPoint.Win.Spread.Model.DefaultSheetAxisModel" orientation="Horizontal" count="500">
|
||||
<Items>
|
||||
<Item index="-1"><Size>80</Size></Item>
|
||||
<Item index="1"><Size>120</Size></Item>
|
||||
</Items>
|
||||
</Column>
|
||||
</AxisModels>
|
||||
<SpanModel class="FarPoint.Win.Spread.Model.DefaultSheetSpanModel">
|
||||
<CellRange Row="0" Column="0" RowCount="1" ColumnCount="2" />
|
||||
</SpanModel>
|
||||
</Sheet>
|
||||
</Sheets>
|
||||
</Presentation>
|
||||
<Data>
|
||||
<Sheets>
|
||||
<Sheet index="0">
|
||||
<DataArea class="FarPoint.Win.Spread.Model.DefaultSheetDataModel" rows="500" columns="500">
|
||||
<Cell Row="0" Column="0"><Data type="System.String">평가 항목</Data></Cell>
|
||||
<Cell Row="1" Column="0"><Data type="System.String">보행</Data></Cell>
|
||||
<Cell Row="1" Column="1"><Data type="System.String">3점</Data></Cell>
|
||||
</DataArea>
|
||||
</Sheet>
|
||||
</Sheets>
|
||||
</Data>
|
||||
</Spread>
|
||||
""";
|
||||
|
||||
/// <summary>합성 샘플 — 축 크기/기본값/스팬/셀 추출</summary>
|
||||
[TestMethod]
|
||||
public void Parse_Synthetic_ExtractsAll()
|
||||
{
|
||||
var grid = FarPointSpreadParser.Parse(SyntheticXml);
|
||||
|
||||
Assert.IsNotNull(grid);
|
||||
Assert.AreEqual(24, grid!.DefaultRowHeight, "defaultSize 어트리뷰트");
|
||||
Assert.AreEqual(80, grid.DefaultColWidth, "index=-1 Item 기본값");
|
||||
Assert.AreEqual(40, grid.RowHeightOf(0));
|
||||
Assert.AreEqual(24, grid.RowHeightOf(1), "미지정 행은 기본값");
|
||||
Assert.AreEqual(60, grid.RowHeightOf(2));
|
||||
Assert.AreEqual(120, grid.ColWidthOf(1));
|
||||
Assert.AreEqual(80, grid.ColWidthOf(0));
|
||||
|
||||
Assert.AreEqual(1, grid.Spans.Count);
|
||||
Assert.AreEqual(2, grid.Spans[0].ColSpan);
|
||||
|
||||
Assert.AreEqual(3, grid.Cells.Count);
|
||||
Assert.AreEqual("평가 항목", grid.Cells[0].Text);
|
||||
Assert.AreEqual(1, grid.Cells[2].Row);
|
||||
Assert.AreEqual(1, grid.Cells[2].Col);
|
||||
}
|
||||
|
||||
/// <summary>실물 기본 골격(S999) — 예외 없이 파싱, 기본 크기 유지</summary>
|
||||
[TestMethod]
|
||||
public void Parse_EmptySkeleton_Succeeds()
|
||||
{
|
||||
const string skeleton = """
|
||||
<Spread class="M.EMR.UserControl.Spread">
|
||||
<Presentation>
|
||||
<Sheets><Sheet index="0">
|
||||
<AxisModels>
|
||||
<Row class="FarPoint.Win.Spread.Model.DefaultSheetAxisModel" orientation="Vertical" count="500">
|
||||
<Items><Item index="-1" /></Items>
|
||||
</Row>
|
||||
<Column class="FarPoint.Win.Spread.Model.DefaultSheetAxisModel" orientation="Horizontal" count="500">
|
||||
<Items><Item index="-1" /></Items>
|
||||
</Column>
|
||||
</AxisModels>
|
||||
</Sheet></Sheets>
|
||||
</Presentation>
|
||||
<Data><Sheets><Sheet index="0">
|
||||
<DataArea class="FarPoint.Win.Spread.Model.DefaultSheetDataModel" rows="500" columns="500" />
|
||||
</Sheet></Sheets></Data>
|
||||
</Spread>
|
||||
""";
|
||||
|
||||
var grid = FarPointSpreadParser.Parse(skeleton);
|
||||
|
||||
Assert.IsNotNull(grid);
|
||||
Assert.AreEqual(20, grid!.DefaultRowHeight);
|
||||
Assert.AreEqual(62, grid.DefaultColWidth);
|
||||
Assert.AreEqual(0, grid.Cells.Count);
|
||||
}
|
||||
|
||||
/// <summary>손상 입력 — null 반환(자리표시 렌더 유지)</summary>
|
||||
[TestMethod]
|
||||
public void Parse_Invalid_ReturnsNull()
|
||||
{
|
||||
Assert.IsNull(FarPointSpreadParser.Parse("not xml at all <"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Core.Tests;
|
||||
|
||||
/// <summary>JSON 부 포맷 테스트 — 레거시 XML 과의 상호 변환 무손실성.</summary>
|
||||
[TestClass]
|
||||
public sealed class FormJsonTests
|
||||
{
|
||||
private static string SamplePath
|
||||
=> Path.Combine(AppContext.BaseDirectory, "Assets", "sheetdesign_sample.xml");
|
||||
|
||||
/// <summary>XML → 모델 → JSON → 모델 → XML 전체 왕복 — 의미론 diff 0</summary>
|
||||
[TestMethod]
|
||||
public void FullRoundTrip_XmlToJsonToXml_NoSemanticDiff()
|
||||
{
|
||||
var original = File.ReadAllText(SamplePath);
|
||||
var xmlSerializer = new LegacyXmlSerializer();
|
||||
var jsonSerializer = new FormJsonSerializer();
|
||||
|
||||
var document = xmlSerializer.Read(original);
|
||||
var json = jsonSerializer.Write(document);
|
||||
var restored = jsonSerializer.Read(json);
|
||||
var rewrittenXml = xmlSerializer.Write(restored);
|
||||
|
||||
var diffs = XmlSemanticDiff.Compare(original, rewrittenXml);
|
||||
Assert.AreEqual(0, diffs.Count,
|
||||
"XML→JSON→XML 왕복 의미론 diff 발생:\n" + string.Join("\n", diffs));
|
||||
}
|
||||
|
||||
/// <summary>JSON 이중 직렬화 안정성 — 두 번째 출력이 첫 번째와 동일</summary>
|
||||
[TestMethod]
|
||||
public void JsonRoundTrip_IsStable()
|
||||
{
|
||||
var xmlSerializer = new LegacyXmlSerializer();
|
||||
var jsonSerializer = new FormJsonSerializer();
|
||||
|
||||
var document = xmlSerializer.Read(File.ReadAllText(SamplePath));
|
||||
var first = jsonSerializer.Write(document);
|
||||
var second = jsonSerializer.Write(jsonSerializer.Read(first));
|
||||
|
||||
Assert.AreEqual(first, second, "JSON 왕복이 안정적이지 않습니다.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
@@ -0,0 +1,86 @@
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Core.Tests;
|
||||
|
||||
/// <summary>레거시 invariant 문자열 포맷 도우미 테스트 — 실샘플 원문 형식과의 일치 검증.</summary>
|
||||
[TestClass]
|
||||
public sealed class LegacyFormatTests
|
||||
{
|
||||
/// <summary>Point/Size 파싱·생성 — "336, 1064" 왕복</summary>
|
||||
[TestMethod]
|
||||
[DataRow("336, 1064", 336, 1064)]
|
||||
[DataRow("0, 22", 0, 22)]
|
||||
[DataRow("-5, 10", -5, 10)]
|
||||
public void Point_RoundTrip(string text, int x, int y)
|
||||
{
|
||||
var (px, py) = LegacyFormat.ParsePoint(text);
|
||||
Assert.AreEqual(x, px);
|
||||
Assert.AreEqual(y, py);
|
||||
Assert.AreEqual(text.Replace(" ", "").Insert(text.Replace(" ", "").IndexOf(',') + 1, " "),
|
||||
LegacyFormat.FormatPair(px, py));
|
||||
}
|
||||
|
||||
/// <summary>폰트 파싱 — 실샘플 원문 3형태</summary>
|
||||
[TestMethod]
|
||||
public void Font_Parse_SampleForms()
|
||||
{
|
||||
var font1 = LegacyFormat.ParseFont("굴림, 11.25pt, style=Bold");
|
||||
Assert.AreEqual("굴림", font1.Family);
|
||||
Assert.AreEqual(11.25, font1.SizePt);
|
||||
Assert.IsTrue(font1.Bold);
|
||||
Assert.IsFalse(font1.Italic);
|
||||
|
||||
var font2 = LegacyFormat.ParseFont("굴림, 11.25pt");
|
||||
Assert.IsFalse(font2.Bold);
|
||||
|
||||
var font3 = LegacyFormat.ParseFont("돋움, 26.25pt, style=Bold, Underline");
|
||||
Assert.AreEqual("돋움", font3.Family);
|
||||
Assert.IsTrue(font3.Bold);
|
||||
Assert.IsTrue(font3.Underline);
|
||||
}
|
||||
|
||||
/// <summary>폰트 생성 — 레거시 FontConverter invariant 형식과 동일</summary>
|
||||
[TestMethod]
|
||||
public void Font_Format_MatchesLegacy()
|
||||
{
|
||||
Assert.AreEqual("굴림, 11.25pt, style=Bold",
|
||||
LegacyFormat.FormatFont(new LegacyFont { Family = "굴림", SizePt = 11.25, Bold = true }));
|
||||
Assert.AreEqual("굴림, 9pt",
|
||||
LegacyFormat.FormatFont(new LegacyFont { Family = "굴림", SizePt = 9 }));
|
||||
Assert.AreEqual("돋움, 26.25pt, style=Bold, Underline",
|
||||
LegacyFormat.FormatFont(new LegacyFont { Family = "돋움", SizePt = 26.25, Bold = true, Underline = true }));
|
||||
}
|
||||
|
||||
/// <summary>폰트 왕복 — 파싱→생성 원문 유지</summary>
|
||||
[TestMethod]
|
||||
[DataRow("굴림, 11.25pt, style=Bold")]
|
||||
[DataRow("굴림, 11.25pt")]
|
||||
[DataRow("돋움, 26.25pt, style=Bold")]
|
||||
public void Font_RoundTrip(string original)
|
||||
{
|
||||
Assert.AreEqual(original, LegacyFormat.FormatFont(LegacyFormat.ParseFont(original)));
|
||||
}
|
||||
|
||||
/// <summary>색 파싱 — RGB 성분/명명색/시스템색</summary>
|
||||
[TestMethod]
|
||||
public void Color_Parse_SampleForms()
|
||||
{
|
||||
Assert.AreEqual(((byte)255, (byte)224, (byte)224, (byte)224), LegacyFormat.ParseColor("224, 224, 224"));
|
||||
Assert.AreEqual(((byte)255, (byte)255, (byte)255, (byte)255), LegacyFormat.ParseColor("White"));
|
||||
|
||||
var transparent = LegacyFormat.ParseColor("Transparent");
|
||||
Assert.AreEqual(0, transparent.A);
|
||||
|
||||
// 시스템색 — 성분값은 OS 테마에 따라 다르므로 해석 성공 여부만 확인
|
||||
var system = LegacyFormat.ParseColor("ControlDarkDark");
|
||||
Assert.IsTrue(system.A > 0, "시스템색 해석 실패");
|
||||
}
|
||||
|
||||
/// <summary>색 생성 — "R, G, B" invariant 형식</summary>
|
||||
[TestMethod]
|
||||
public void Color_Format_MatchesLegacy()
|
||||
{
|
||||
Assert.AreEqual("224, 224, 224", LegacyFormat.FormatColor(255, 224, 224, 224));
|
||||
Assert.AreEqual("128, 255, 0, 0", LegacyFormat.FormatColor(128, 255, 0, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Core.Tests;
|
||||
|
||||
/// <summary>레거시 타입 매핑 카탈로그 테스트.</summary>
|
||||
[TestClass]
|
||||
public sealed class LegacyTypeCatalogTests
|
||||
{
|
||||
/// <summary>레거시 클래스 → 중립 타입 매핑 (변형 포함)</summary>
|
||||
[TestMethod]
|
||||
[DataRow("M.EMR.UserControl.TextBox, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "TextBox")]
|
||||
[DataRow("M.EMR.UserControl.MaskedTextBox, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "MaskedTextBox")]
|
||||
[DataRow("M.EMR.UserControl.MFormatLabel, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Label")]
|
||||
[DataRow("M.EMR.UserControl.MSequence, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Label")]
|
||||
[DataRow("M.EMR.UserControl.CheckLabel, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "CheckBox")]
|
||||
[DataRow("M.EMR.UserControl.MCheckedListBox, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "CheckList")]
|
||||
[DataRow("M.EMR.UserControl.Panel2, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Panel")]
|
||||
[DataRow("M.EMR.UserControl.MExpandablePanel, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Panel")]
|
||||
[DataRow("M.EMR.UserControl.MLine, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Line")]
|
||||
[DataRow("M.EMR.UserControl.MPictureBox, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "PictureBox")]
|
||||
[DataRow("M.EMR.UserControl.MCalcBox, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "CalcBox")]
|
||||
public void Resolve_MapsLegacyTypes(string aqn, string expected)
|
||||
{
|
||||
Assert.AreEqual(expected, LegacyTypeCatalog.Resolve(aqn));
|
||||
}
|
||||
|
||||
/// <summary>미지원 타입 → Placeholder</summary>
|
||||
[TestMethod]
|
||||
[DataRow("M.EMR.UserControl.MSign, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")]
|
||||
[DataRow("M.EMR.UserControl.MFile, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")]
|
||||
[DataRow("Some.Unknown.CustomControl, Some.Assembly")]
|
||||
public void Resolve_UnknownTypes_ReturnPlaceholder(string aqn)
|
||||
{
|
||||
Assert.AreEqual(LegacyTypeCatalog.PlaceholderType, LegacyTypeCatalog.Resolve(aqn));
|
||||
}
|
||||
|
||||
/// <summary>네이티브 지원 승격 타입 — MButton/MDataTable/Spread(렌더 전용)</summary>
|
||||
[TestMethod]
|
||||
[DataRow("M.EMR.UserControl.MButton, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Button")]
|
||||
[DataRow("M.EMR.UserControl.MDataTable, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "DataTable")]
|
||||
[DataRow("M.EMR.UserControl.Spread, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Spread")]
|
||||
public void Resolve_NativeUpgradedTypes(string aqn, string expected)
|
||||
{
|
||||
Assert.AreEqual(expected, LegacyTypeCatalog.Resolve(aqn));
|
||||
}
|
||||
|
||||
/// <summary>팔레트 14종 전부 신규 생성 AQN 보유 + 왕복 매핑 일관성</summary>
|
||||
[TestMethod]
|
||||
public void AqnOf_AllPaletteTypes_RoundTripConsistent()
|
||||
{
|
||||
foreach (var descriptor in ControlRegistry.All)
|
||||
{
|
||||
var aqn = LegacyTypeCatalog.AqnOf(descriptor.Type);
|
||||
Assert.IsNotNull(aqn, $"{descriptor.Type} 의 AQN 이 없습니다.");
|
||||
Assert.AreEqual(descriptor.Type, LegacyTypeCatalog.Resolve(aqn!),
|
||||
$"{descriptor.Type} 의 AQN({aqn})이 자기 자신으로 해석되지 않습니다.");
|
||||
StringAssert.Contains(aqn!, "M.EMR.UserControl", $"{descriptor.Type} AQN 어셈블리 누락");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>MDesignerHost 판정</summary>
|
||||
[TestMethod]
|
||||
public void IsHost_DetectsDesignerHost()
|
||||
{
|
||||
Assert.IsTrue(LegacyTypeCatalog.IsHost(LegacyTypeCatalog.HostAqn));
|
||||
Assert.IsFalse(LegacyTypeCatalog.IsHost("M.EMR.UserControl.TextBox, M.EMR.UserControl"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
|
||||
namespace SheetMe.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 레거시 XML 왕복 무결성 테스트 — P0 게이트.
|
||||
/// 실서식 샘플(sheetdesign_sample.xml)을 Read→Write 했을 때 의미론 diff 가 0 이어야 한다.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class LegacyXmlRoundTripTests
|
||||
{
|
||||
private static string SamplePath
|
||||
=> Path.Combine(AppContext.BaseDirectory, "Assets", "sheetdesign_sample.xml");
|
||||
|
||||
/// <summary>실서식 샘플 왕복 — 의미론 diff 0</summary>
|
||||
[TestMethod]
|
||||
public void RoundTrip_RealSample_NoSemanticDiff()
|
||||
{
|
||||
var original = File.ReadAllText(SamplePath);
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
|
||||
var document = serializer.Read(original);
|
||||
var rewritten = serializer.Write(document);
|
||||
|
||||
var diffs = XmlSemanticDiff.Compare(original, rewritten);
|
||||
Assert.AreEqual(0, diffs.Count,
|
||||
"왕복 의미론 diff 발생:\n" + string.Join("\n", diffs));
|
||||
}
|
||||
|
||||
/// <summary>실서식 샘플 이중 왕복 — Write 출력이 안정(고정점)이어야 한다</summary>
|
||||
[TestMethod]
|
||||
public void RoundTrip_RealSample_SecondPassIsStable()
|
||||
{
|
||||
var original = File.ReadAllText(SamplePath);
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
|
||||
var first = serializer.Write(serializer.Read(original));
|
||||
var second = serializer.Write(serializer.Read(first));
|
||||
|
||||
Assert.AreEqual(first, second, "이중 왕복 결과가 문자열 수준에서 안정적이지 않습니다.");
|
||||
}
|
||||
|
||||
/// <summary>실서식 샘플 구조 확인 — 페이지/컨트롤 수·타입 매핑</summary>
|
||||
[TestMethod]
|
||||
public void Read_RealSample_ParsesStructure()
|
||||
{
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
var document = serializer.Read(File.ReadAllText(SamplePath));
|
||||
|
||||
Assert.AreEqual(1, document.Pages.Count, "샘플은 단일 페이지여야 합니다.");
|
||||
var page = document.Pages[0];
|
||||
Assert.AreEqual("MDesignerHost1", page.Root.Id);
|
||||
Assert.AreEqual(823, page.Width, "루트 Size 리프트(823×1250) 실패");
|
||||
Assert.AreEqual(1250, page.Height);
|
||||
|
||||
// 샘플 실측: MLine 34, TextBox 33, Label 27, RadioButton 15, Panel 6, MSequence 1, MPictureBox 1
|
||||
var allControls = Flatten(page.Controls).ToList();
|
||||
Assert.AreEqual(117, allControls.Count, "전체 컨트롤 수(루트 제외) 불일치");
|
||||
Assert.AreEqual(34, allControls.Count(c => c.Type == "Line"));
|
||||
Assert.AreEqual(33, allControls.Count(c => c.Type == "TextBox"));
|
||||
Assert.AreEqual(28, allControls.Count(c => c.Type == "Label"), "Label 27 + MSequence 1 = 28");
|
||||
Assert.AreEqual(15, allControls.Count(c => c.Type == "RadioButton"));
|
||||
Assert.AreEqual(6, allControls.Count(c => c.Type == "Panel"));
|
||||
Assert.AreEqual(1, allControls.Count(c => c.Type == "PictureBox"));
|
||||
Assert.AreEqual(0, allControls.Count(c => c.Type == LegacyTypeCatalog.PlaceholderType),
|
||||
"샘플에는 미지원 타입이 없어야 합니다.");
|
||||
}
|
||||
|
||||
/// <summary>다중 페이지 병합 규약 — Sheet 루트 1개에 MDesignerHost Object N개</summary>
|
||||
[TestMethod]
|
||||
public void RoundTrip_MultiPage_PreservesPages()
|
||||
{
|
||||
const string xml =
|
||||
"<Sheet name=\"SheetDesignInfo\">" +
|
||||
"<Object type=\"M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null\" name=\"MDesignerHost1\" children=\"Controls\" DisplaySequence=\"0\">" +
|
||||
"<Property name=\"Size\">720, 856</Property>" +
|
||||
"</Object>" +
|
||||
"<Object type=\"M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null\" name=\"MDesignerHost1\" children=\"Controls\" DisplaySequence=\"0\">" +
|
||||
"<Property name=\"Size\">720, 856</Property>" +
|
||||
"</Object>" +
|
||||
"</Sheet>";
|
||||
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
var document = serializer.Read(xml);
|
||||
|
||||
Assert.AreEqual(2, document.Pages.Count);
|
||||
|
||||
var rewritten = serializer.Write(document);
|
||||
var diffs = XmlSemanticDiff.Compare(xml, rewritten);
|
||||
Assert.AreEqual(0, diffs.Count, string.Join("\n", diffs));
|
||||
}
|
||||
|
||||
/// <summary>신규 컨트롤 추가 후 쓰기 — Location/Size/AQN 이 레거시 형식으로 생성</summary>
|
||||
[TestMethod]
|
||||
public void Write_NewControl_EmitsLegacyFormat()
|
||||
{
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
var document = new FormDocument();
|
||||
var page = LegacyXmlSerializer.CreateEmptyPage(1);
|
||||
document.Pages.Add(page);
|
||||
|
||||
var textBox = new ControlElement
|
||||
{
|
||||
Type = "TextBox",
|
||||
Id = "TextBox1",
|
||||
Bounds = new LayoutRect { X = 120, Y = 88, W = 200, H = 24 },
|
||||
};
|
||||
textBox.Props.SetText("Text", "환자명");
|
||||
page.Controls.Add(textBox);
|
||||
|
||||
var xml = serializer.Write(document);
|
||||
|
||||
StringAssert.Contains(xml, "M.EMR.UserControl.TextBox, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");
|
||||
StringAssert.Contains(xml, "<Property name=\"Location\">120, 88</Property>");
|
||||
StringAssert.Contains(xml, "<Property name=\"Size\">200, 24</Property>");
|
||||
StringAssert.Contains(xml, "<Property name=\"Text\">환자명</Property>");
|
||||
|
||||
// 재읽기 시 동일 구조
|
||||
var reread = serializer.Read(xml);
|
||||
Assert.AreEqual(1, reread.Pages[0].Controls.Count);
|
||||
Assert.AreEqual("TextBox", reread.Pages[0].Controls[0].Type);
|
||||
Assert.AreEqual(120, reread.Pages[0].Controls[0].Bounds.X);
|
||||
}
|
||||
|
||||
/// <summary>컨테이너 중첩 시 LocationOnBase 재계산 — 절대좌표(부모 누적) 규약</summary>
|
||||
[TestMethod]
|
||||
public void Write_NestedControl_RecomputesLocationOnBase()
|
||||
{
|
||||
const string xml =
|
||||
"<Sheet name=\"SheetDesignInfo\">" +
|
||||
"<Object type=\"M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null\" name=\"MDesignerHost1\" children=\"Controls\" DisplaySequence=\"0\">" +
|
||||
"<Object type=\"M.EMR.UserControl.Panel, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null\" name=\"Panel1\" children=\"Controls\" DisplaySequence=\"0\">" +
|
||||
"<Object type=\"M.EMR.UserControl.TextBox, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null\" name=\"TextBox1\" children=\"Controls\" DisplaySequence=\"0\">" +
|
||||
"<Property name=\"LocationOnBase\">0, 0</Property>" +
|
||||
"<Property name=\"Location\">10, 20</Property>" +
|
||||
"<Property name=\"Size\">100, 22</Property>" +
|
||||
"</Object>" +
|
||||
"<Property name=\"Location\">50, 60</Property>" +
|
||||
"<Property name=\"Size\">200, 100</Property>" +
|
||||
"</Object>" +
|
||||
"<Property name=\"Size\">720, 856</Property>" +
|
||||
"</Object>" +
|
||||
"</Sheet>";
|
||||
|
||||
var serializer = new LegacyXmlSerializer();
|
||||
var document = serializer.Read(xml);
|
||||
var rewritten = serializer.Write(document);
|
||||
|
||||
// Panel(50,60) 안의 TextBox(10,20) → LocationOnBase = 60, 80 (레거시 writer 재계산 규약)
|
||||
StringAssert.Contains(rewritten, "<Property name=\"LocationOnBase\">60, 80</Property>");
|
||||
}
|
||||
|
||||
private static IEnumerable<ControlElement> Flatten(IEnumerable<ControlElement> controls)
|
||||
{
|
||||
foreach (var control in controls)
|
||||
{
|
||||
yield return control;
|
||||
foreach (var child in Flatten(control.Children))
|
||||
{
|
||||
yield return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user