commit 16c07f48dc4ae146c73344f816c47a21e6177134 Author: Msystech Date: Tue Aug 11 17:20:22 2026 +0900 초기 커밋: 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c025798 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +bin/ +obj/ +publish/ +*.user +*.suo +.vs/ + +# 실접속 정보 — 절대 커밋하지 않는다. 배포 산출물에도 포함되지 않는다(csproj Debug 조건부 복사). +appsettings.Development.json + +# 진단 리포트 산출물 +*-smoke.txt +baseline.txt diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..d720db3 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,16 @@ + + + + + MSYSTECH + SheetMe (서식생성기) + Copyright © $([System.DateTime]::Now.Year) Msystech. All rights reserved. + 1.0.0.0 + latest + enable + enable + false + false + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..295ebec --- /dev/null +++ b/README.md @@ -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: `` 루트에 페이지당 ``(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 는 저장 시 재계산(부모 절대좌표 누적), `` 는 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` diff --git a/SheetMe.slnx b/SheetMe.slnx new file mode 100644 index 0000000..1a82c04 --- /dev/null +++ b/SheetMe.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/SheetMe.Core/Catalog/ControlRegistry.cs b/src/SheetMe.Core/Catalog/ControlRegistry.cs new file mode 100644 index 0000000..bafde78 --- /dev/null +++ b/src/SheetMe.Core/Catalog/ControlRegistry.cs @@ -0,0 +1,258 @@ +namespace SheetMe.Core.Catalog; + +/// +/// 팔레트 컨트롤 카탈로그 — 팔레트 목록·기본 크기·필드 생성 규칙의 단일 출처. +/// 속성 편집 스키마(PropertyDef)는 P3(인스펙터)에서 확장한다. +/// +public static class ControlRegistry +{ + #region Member Fields + private static readonly List 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 byType = + all.ToDictionary(d => d.Type, StringComparer.Ordinal); + #endregion + + #region Properties + /// 팔레트에 노출할 전체 컨트롤 목록 + public static IReadOnlyList All => all; + #endregion + + #region Methods + /// 타입명으로 기술자 조회 — 미지원 타입(Placeholder 포함)은 null + public static ControlDescriptor? Find(string type) + => byType.TryGetValue(type, out var descriptor) ? descriptor : null; + #endregion +} + +/// 팔레트 컨트롤 1종의 기술자 +public sealed class ControlDescriptor +{ + /// 중립 타입명(LegacyTypeCatalog 와 공유하는 키) + public string Type { get; init; } = string.Empty; + + /// 팔레트 표시명(한글) + public string DisplayName { get; init; } = string.Empty; + + /// 드롭 시 기본 너비(px) + public double DefaultW { get; init; } + + /// 드롭 시 기본 높이(px) + public double DefaultH { get; init; } + + /// 컨테이너 여부(Panel/GroupBox — 자식 배치 가능) + public bool IsContainer { get; init; } + + /// 배치 시 데이터 필드를 만드는 입력 컨트롤인지 + public bool CreatesField { get; init; } + + /// 필드 기본 데이터 타입 + public string DefaultDataType { get; init; } = "string"; + + /// 인스펙터 속성 편집 스키마(타입 전용 — 공통 X/Y/W/H/Id/폰트는 인스펙터가 별도 제공) + public IReadOnlyList Properties { get; init; } = Array.Empty(); +} + +/// 속성 편집기 종류 +public enum PropEditorKind +{ + /// 문자열 + Text, + /// 여러 줄 문자열 + MultilineText, + /// 숫자 + Number, + /// 참/거짓 + Toggle, + /// 선택지 + Choice, + /// 색(레거시 invariant 문자열) + Color, + /// 문자열 목록(콤보 items 등 — 줄바꿈 구분 편집) + StringList, + /// 데이터 태그 피커(bzDataInterface 메서드 — 자동 채움 원천) + DataInterfaceTag, + /// 액션 태그 피커(EN_DataActionTyp — 더블클릭/버튼 액션) + DataActionTag, + /// SQL 쿼리 — 전용 편집기 창(치환 변수 삽입 지원) + SqlQuery, +} + +/// 인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집 +public sealed class PropertyDef +{ + /// PropBag 키(레거시 Property 이름) + public string Key { get; init; } = string.Empty; + + /// 표시 라벨(한글) + public string Label { get; init; } = string.Empty; + + /// 편집기 종류 + public PropEditorKind Editor { get; init; } + + /// Choice 편집기의 선택지 + public string[]? Choices { get; init; } +} diff --git a/src/SheetMe.Core/Catalog/LegacyTagCatalog.cs b/src/SheetMe.Core/Catalog/LegacyTagCatalog.cs new file mode 100644 index 0000000..21b837a --- /dev/null +++ b/src/SheetMe.Core/Catalog/LegacyTagCatalog.cs @@ -0,0 +1,556 @@ +namespace SheetMe.Core.Catalog; + +/// +/// 레거시 태그 값 카탈로그 — 인스펙터 피커의 선택지. +/// 원본: [003]EMR\[002]UserControl\clsBaseUserControl.vb 의 EN_DataActionTyp(선언 순서 보존 — 값 의미상 순서 변경 금지), +/// [001]Common\[012]DataTag\M.CMM.DataTag\bzDataInterface.vb 의 Public Function 목록 (2026-07-16 추출). +/// 사이트 커스텀 태그는 텍스트 직접 입력으로 지원(피커는 선택 보조). +/// +public static class LegacyTagCatalog +{ + /// 액션 태그(EN_DataActionTyp) — 더블클릭/버튼 액션 종류 + 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", + }; + + /// 데이터 태그(bzDataInterface 메서드) — 자동 채움 데이터 원천 + 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", + }; +} diff --git a/src/SheetMe.Core/Models/ControlElement.cs b/src/SheetMe.Core/Models/ControlElement.cs new file mode 100644 index 0000000..d3dee8c --- /dev/null +++ b/src/SheetMe.Core/Models/ControlElement.cs @@ -0,0 +1,88 @@ +namespace SheetMe.Core.Models; + +/// +/// 배치된 컨트롤 1개 — 레거시 XML의 <Object> 노드 1개에 대응. +/// 모든 Property 는 에 원본 순서로 무손실 보존되고, +/// 편집 편의를 위해 Location/Size 만 로 리프트한다 +/// (편집 중 Props 의 Location/Size 는 stale — 쓰기 시 Bounds 값으로 동기화된다). +/// +public sealed class ControlElement +{ + #region Properties + /// 중립 타입명 — ControlRegistry/LegacyTypeCatalog 의 키. 미지원 타입은 "Placeholder" + public string Type { get; set; } = string.Empty; + + /// 컨트롤 이름(name 어트리뷰트) — 레거시 SctObjNam 등가, 문서 내 유일해야 함 + public string Id { get; set; } = string.Empty; + + /// 원본 AssemblyQualifiedName — 읽기 시 원문 보존, 신규 생성 시 카탈로그에서 채움 + public string? LegacyAqn { get; set; } + + /// 원본에 name 어트리뷰트가 있었는지 (왕복 보존) + public bool HasNameAttr { get; set; } = true; + + /// 원본에 children 어트리뷰트가 있었는지 (Control 이면 "Controls") + public bool HasChildrenAttr { get; set; } = true; + + /// Object 어트리뷰트 DisplaySequence 값 (레거시 writer 는 항상 0 기록) + public int DisplaySequence { get; set; } + + /// 부모 기준 배치(px) — Props 의 Location/Size 리프트 + public LayoutRect Bounds { get; set; } = new(); + + /// 원본 Props 에 Location 이 있었는지 — 쓰기 시 생략/기록 판단 + public bool HasLocation { get; set; } + + /// 원본 Props 에 Size 가 있었는지 — 쓰기 시 생략/기록 판단 + public bool HasSize { get; set; } + + /// 전체 속성 가방(원본 순서 보존) — Location/Size 포함(쓰기 시 Bounds 로 동기화) + public PropBag Props { get; set; } = new(); + + /// 자식 컨트롤 — 레거시 Controls 컬렉션 순서 그대로(index 0 = 최상위 z-order) + public List Children { get; set; } = new(); + + /// 디자이너 편집 잠금(직렬화 안 함 — 편집 전용 상태) + [System.Text.Json.Serialization.JsonIgnore] + public bool Locked { get; set; } + + /// 디자이너 임시 숨김(직렬화 안 함 — 편집 전용 상태, 레거시 Visible 과 별개) + [System.Text.Json.Serialization.JsonIgnore] + public bool Hidden { get; set; } + + /// 디자이너 그룹 식별자(직렬화 안 함 — 편집 전용 상태) + [System.Text.Json.Serialization.JsonIgnore] + public string? GroupId { get; set; } + #endregion + + #region Methods + /// 텍스트 속성(Text) 편의 접근 — 없으면 빈 문자열 + public string GetText() => Props.GetText("Text") ?? string.Empty; + + /// 깊은 복제(자식·속성 포함) + 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 +} diff --git a/src/SheetMe.Core/Models/FormDocument.cs b/src/SheetMe.Core/Models/FormDocument.cs new file mode 100644 index 0000000..abcdc80 --- /dev/null +++ b/src/SheetMe.Core/Models/FormDocument.cs @@ -0,0 +1,40 @@ +namespace SheetMe.Core.Models; + +/// +/// 서식 문서 — 직렬화(레거시 XML/JSON)의 단위. 페이지 목록과 메타데이터를 갖는다. +/// FormId/Title 은 XML 본문이 아닌 DB(E_ShtMst)에서 오는 메타데이터다. +/// +public sealed class FormDocument +{ + #region Properties + /// 서식 코드 — E_ShtMst.ShtCod (파일 기반일 때는 파일명 유래) + public string FormId { get; set; } = string.Empty; + + /// 서식 명칭 — E_ShtMst.ShtKorNam + public string Title { get; set; } = string.Empty; + + /// 페이지 목록(순서 = 페이지 순서) + public List Pages { get; set; } = new(); + + /// 부가 메타(출처/감사/읽기 경고) + public FormMeta Meta { get; set; } = new(); + #endregion + + #region Methods + /// 깊은 복제 — Undo 스냅샷용(편집 전용 상태 포함) + 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 +} diff --git a/src/SheetMe.Core/Models/FormMeta.cs b/src/SheetMe.Core/Models/FormMeta.cs new file mode 100644 index 0000000..862567f --- /dev/null +++ b/src/SheetMe.Core/Models/FormMeta.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace SheetMe.Core.Models; + +/// 서식 문서 부가 메타 — 출처·감사 정보와 읽기 시 수집된 경고. +public sealed class FormMeta +{ + #region Properties + /// 레거시 가져오기 출처 디자인 키 — E_SdgMst.SdgKey + public decimal? SourceSdgKey { get; set; } + + /// 최종 수정자 + public string? UpdatedBy { get; set; } + + /// 최종 수정일시(yyyyMMddHHmmss) + public string? UpdatedAt { get; set; } + + /// 읽기(파싱) 중 수집된 경고 — 직렬화하지 않음 + [JsonIgnore] + public List ReadWarnings { get; } = new(); + #endregion + + #region Methods + /// 복제(경고 제외) + public FormMeta Clone() => new() + { + SourceSdgKey = SourceSdgKey, + UpdatedBy = UpdatedBy, + UpdatedAt = UpdatedAt, + }; + #endregion +} diff --git a/src/SheetMe.Core/Models/FormPage.cs b/src/SheetMe.Core/Models/FormPage.cs new file mode 100644 index 0000000..c1f60e9 --- /dev/null +++ b/src/SheetMe.Core/Models/FormPage.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace SheetMe.Core.Models; + +/// +/// 서식 페이지 1장 — 레거시의 MDesignerHost 루트 <Object> 1개에 대응. +/// 루트 노드 자체를 (ControlElement)로 보존해 루트 속성까지 무손실 왕복한다. +/// +public sealed class FormPage +{ + #region Properties + /// 페이지 루트(MDesignerHost) — 자식이 페이지의 컨트롤들 + public ControlElement Root { get; set; } = new(); + + /// 페이지의 컨트롤 목록(루트의 자식) — index 0 = 최상위 z-order(레거시 규약) + [JsonIgnore] + public List Controls => Root.Children; + + /// 페이지 너비(px) — 루트 Size 리프트, 레거시 기본 720 + [JsonIgnore] + public double Width + { + get => Root.Bounds.W > 0 ? Root.Bounds.W : 720; + set => Root.Bounds.W = value; + } + + /// 페이지 높이(px) — 루트 Size 리프트, 레거시 기본 856 + [JsonIgnore] + public double Height + { + get => Root.Bounds.H > 0 ? Root.Bounds.H : 856; + set => Root.Bounds.H = value; + } + #endregion + + #region Methods + /// 깊은 복제 + public FormPage Clone() => new() { Root = Root.Clone() }; + #endregion +} diff --git a/src/SheetMe.Core/Models/LayoutRect.cs b/src/SheetMe.Core/Models/LayoutRect.cs new file mode 100644 index 0000000..2f31888 --- /dev/null +++ b/src/SheetMe.Core/Models/LayoutRect.cs @@ -0,0 +1,26 @@ +namespace SheetMe.Core.Models; + +/// +/// 배치 사각형 — 부모(페이지 루트 또는 컨테이너) 기준 상대 좌표(px, WinForms px = WPF DIP 1:1). +/// +public sealed class LayoutRect +{ + #region Properties + /// 부모 기준 X(px) + public double X { get; set; } + + /// 부모 기준 Y(px) + public double Y { get; set; } + + /// 너비(px) + public double W { get; set; } + + /// 높이(px) + public double H { get; set; } + #endregion + + #region Methods + /// 복제 + public LayoutRect Clone() => new() { X = X, Y = Y, W = W, H = H }; + #endregion +} diff --git a/src/SheetMe.Core/Models/LegacyPropValue.cs b/src/SheetMe.Core/Models/LegacyPropValue.cs new file mode 100644 index 0000000..b00dd22 --- /dev/null +++ b/src/SheetMe.Core/Models/LegacyPropValue.cs @@ -0,0 +1,84 @@ +namespace SheetMe.Core.Models; + +/// +/// 레거시 서식 XML의 Property 값 변형(variant) 기본형. +/// 레거시 직렬화기(clsBasicDesignerLoader.WriteValue)가 만들어내는 5가지 형태 + 빈 값(Null)을 +/// 원형 그대로 보존해 무손실 왕복을 보장한다. +/// +public abstract class LegacyPropValue +{ + /// 텍스트 값 — <Property>text</Property> (TypeConverter invariant 문자열) + public sealed class TextValue : LegacyPropValue + { + /// 속성 문자열 값 + public string Value { get; set; } = string.Empty; + + public TextValue() { } + public TextValue(string value) => Value = value; + + public override LegacyPropValue Clone() => new TextValue(Value); + } + + /// 빈 값 — <Property /> 또는 내용 없는 요소 (로더에서 null 로 설정됨) + public sealed class NullValue : LegacyPropValue + { + public override LegacyPropValue Clone() => new NullValue(); + } + + /// 중첩 속성 — Content 직렬화 속성의 자식 <Property> 목록 (예: DataBindings) + public sealed class NestedValue : LegacyPropValue + { + /// 자식 Property 가방 + public PropBag Children { get; set; } = new(); + + public override LegacyPropValue Clone() => new NestedValue { Children = Children.Clone() }; + } + + /// 컬렉션 항목 — Content IList 속성의 <Item type="AQN"> 목록 (예: ComboBox Items) + public sealed class ItemsValue : LegacyPropValue + { + /// 항목 목록 + public List 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; + } + } + + /// 바이너리 값 — <Binary>base64</Binary> (BinaryFormatter/Byte[] 직렬화 잔재, verbatim 보존) + public sealed class BinaryValue : LegacyPropValue + { + /// base64 원문 + public string Base64 { get; set; } = string.Empty; + + public override LegacyPropValue Clone() => new BinaryValue { Base64 = Base64 }; + } + + /// 컴포넌트 참조 — <Reference name="..." /> (같은 호스트 내 사이트 참조) + public sealed class ReferenceValue : LegacyPropValue + { + /// 참조 대상 컴포넌트 이름 + public string Name { get; set; } = string.Empty; + + public override LegacyPropValue Clone() => new ReferenceValue { Name = Name }; + } + + /// 깊은 복제 + public abstract LegacyPropValue Clone(); +} + +/// Content IList 속성의 항목 1개 — <Item type="AQN">값</Item> +public sealed class LegacyItem +{ + /// 항목 타입의 AssemblyQualifiedName 원문 + public string Aqn { get; set; } = string.Empty; + + /// 항목 값(대개 TextValue) + public LegacyPropValue Value { get; set; } = new LegacyPropValue.NullValue(); +} diff --git a/src/SheetMe.Core/Models/PropBag.cs b/src/SheetMe.Core/Models/PropBag.cs new file mode 100644 index 0000000..4595a29 --- /dev/null +++ b/src/SheetMe.Core/Models/PropBag.cs @@ -0,0 +1,102 @@ +using System.Collections; + +namespace SheetMe.Core.Models; + +/// +/// 순서 보존 속성 가방 — 레거시 XML의 <Property> 목록을 원본 순서 그대로 담는다. +/// 값 갱신 시 원래 위치를 유지하고, 새 키는 뒤에 추가되어 왕복(diff) 안정성을 보장한다. +/// +public sealed class PropBag : IEnumerable> +{ + #region Member Fields + private readonly List keys = new(); + private readonly Dictionary map = new(StringComparer.Ordinal); + #endregion + + #region Properties + /// 속성 개수 + public int Count => keys.Count; + + /// 속성 이름 목록(원본 순서) + public IReadOnlyList Keys => keys; + #endregion + + #region Methods + /// 속성 값 조회 — 없으면 null + public LegacyPropValue? Get(string name) + => map.TryGetValue(name, out var value) ? value : null; + + /// 텍스트 속성 값 조회 — TextValue 가 아니면 null + public string? GetText(string name) + => Get(name) is LegacyPropValue.TextValue text ? text.Value : null; + + /// 속성 존재 여부 + public bool Contains(string name) => map.ContainsKey(name); + + /// 속성 설정 — 기존 키는 위치 유지, 새 키는 뒤에 추가 + public void Set(string name, LegacyPropValue value) + { + if (!map.ContainsKey(name)) + { + keys.Add(name); + } + map[name] = value; + } + + /// 텍스트 속성 설정 + public void SetText(string name, string value) + => Set(name, new LegacyPropValue.TextValue(value)); + + /// 속성 제거 + public bool Remove(string name) + { + if (!map.Remove(name)) + { + return false; + } + keys.Remove(name); + return true; + } + + /// 지정 키 바로 앞에 새 키 삽입 — 기준 키가 없으면 뒤에 추가 + 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; + } + + /// 깊은 복제 + public PropBag Clone() + { + var clone = new PropBag(); + foreach (var key in keys) + { + clone.Set(key, map[key].Clone()); + } + return clone; + } + + public IEnumerator> GetEnumerator() + { + foreach (var key in keys) + { + yield return new KeyValuePair(key, map[key]); + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + #endregion +} diff --git a/src/SheetMe.Core/Serialization/FarPointSpreadParser.cs b/src/SheetMe.Core/Serialization/FarPointSpreadParser.cs new file mode 100644 index 0000000..aaa6e76 --- /dev/null +++ b/src/SheetMe.Core/Serialization/FarPointSpreadParser.cs @@ -0,0 +1,145 @@ +using System.Xml; + +namespace SheetMe.Core.Serialization; + +/// Spread 셀 1개(텍스트/스팬) +public sealed class SpreadCellInfo +{ + /// 행 인덱스(0-base) + public int Row { get; init; } + + /// 열 인덱스(0-base) + public int Col { get; init; } + + /// 행 스팬(기본 1) + public int RowSpan { get; init; } = 1; + + /// 열 스팬(기본 1) + public int ColSpan { get; init; } = 1; + + /// 셀 텍스트 + public string Text { get; init; } = string.Empty; +} + +/// Spread 격자 파싱 결과 — 렌더에 필요한 최소 정보 +public sealed class SpreadGridInfo +{ + /// 기본 행 높이(px) — FarPoint 기본 20 + public double DefaultRowHeight { get; set; } = 20; + + /// 기본 열 너비(px) — FarPoint 기본 62 + public double DefaultColWidth { get; set; } = 62; + + /// 행별 명시 높이(index → px) + public Dictionary RowHeights { get; } = new(); + + /// 열별 명시 너비(index → px) + public Dictionary ColWidths { get; } = new(); + + /// 텍스트 있는 셀 목록 + public List Cells { get; } = new(); + + /// 셀 스팬 목록(텍스트 없어도 병합 표시) + public List Spans { get; } = new(); + + /// 행 높이 조회 + public double RowHeightOf(int row) => RowHeights.TryGetValue(row, out var h) ? h : DefaultRowHeight; + + /// 열 너비 조회 + public double ColWidthOf(int col) => ColWidths.TryGetValue(col, out var w) ? w : DefaultColWidth; +} + +/// +/// FarPoint Spread 직렬화 XML(E_SpdMst.SpdDesign) 파서 — 렌더용 최소 추출. +/// 근거: 실DB S999/P062 샘플(기본 골격) + FarPoint 표준 직렬화 구조. +/// 셀 데이터(<Cell Row Column>)와 스팬(CellRange)은 실물 부재로 합성 샘플 검증 — +/// 셀 있는 실서식 확보 시 재검증 필요(주석 유지). +/// +public static class FarPointSpreadParser +{ + #region Methods + /// SpdDesign XML 파싱 — 실패 시 null(호출부가 자리표시 렌더 유지) + 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 하위 의 텍스트 + 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; + } + } + + /// 축 Items 파싱 — Item(index, Size). index=-1 은 기본 크기 지정 + private static void ParseAxis(XmlElement axis, Dictionary sizes, Action 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 +} diff --git a/src/SheetMe.Core/Serialization/FormJsonSerializer.cs b/src/SheetMe.Core/Serialization/FormJsonSerializer.cs new file mode 100644 index 0000000..0fd10b4 --- /dev/null +++ b/src/SheetMe.Core/Serialization/FormJsonSerializer.cs @@ -0,0 +1,233 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SheetMe.Core.Models; + +namespace SheetMe.Core.Serialization; + +/// +/// JSON 직렬화기 — 부 포맷(파일 내보내기/백업, 추후 주 포맷 승격 대비). +/// 모델 트리를 있는 그대로 담아 레거시 XML 과 동일한 정보량을 유지한다. +/// +public sealed class FormJsonSerializer : IDesignSerializer +{ + #region Member Fields + private static readonly JsonSerializerOptions options = CreateOptions(); + #endregion + + #region Methods + /// JSON 을 서식 문서로 역직렬화 + public FormDocument Read(string content) + => JsonSerializer.Deserialize(content, options) + ?? throw new InvalidDataException("JSON 서식 문서를 해석할 수 없습니다."); + + /// 서식 문서를 JSON 으로 직렬화 + 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 +} + +/// PropBag JSON 컨버터 — 순서 보존을 위해 [{ "n": 이름, "v": 값 }] 배열로 직렬화 +public sealed class PropBagJsonConverter : JsonConverter +{ + 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(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(); + } +} + +/// LegacyPropValue 다형 JSON 컨버터 — "k" 판별자 기반 +public sealed class LegacyPropValueJsonConverter : JsonConverter +{ + 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? 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(ref reader, options); break; + case "items": + items = new List(); + 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(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() }, + "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; + } + } +} diff --git a/src/SheetMe.Core/Serialization/IDesignSerializer.cs b/src/SheetMe.Core/Serialization/IDesignSerializer.cs new file mode 100644 index 0000000..66825ce --- /dev/null +++ b/src/SheetMe.Core/Serialization/IDesignSerializer.cs @@ -0,0 +1,16 @@ +using SheetMe.Core.Models; + +namespace SheetMe.Core.Serialization; + +/// +/// 서식 문서 직렬화 계약 — v1 주 포맷은 레거시 XML(), +/// 부 포맷은 JSON(). 추후 JSON 주 포맷 승격 시 교체 지점. +/// +public interface IDesignSerializer +{ + /// 문자열 콘텐츠를 서식 문서로 역직렬화 + FormDocument Read(string content); + + /// 서식 문서를 문자열로 직렬화 + string Write(FormDocument document); +} diff --git a/src/SheetMe.Core/Serialization/LegacyFormat.cs b/src/SheetMe.Core/Serialization/LegacyFormat.cs new file mode 100644 index 0000000..8afb250 --- /dev/null +++ b/src/SheetMe.Core/Serialization/LegacyFormat.cs @@ -0,0 +1,181 @@ +using System.Globalization; + +namespace SheetMe.Core.Serialization; + +/// +/// 레거시 TypeConverter invariant 문자열 포맷 도우미. +/// 읽기는 관용적으로, 쓰기는 레거시 원문 형식("X, Y" / "이름, 11.25pt, style=Bold")과 동일하게. +/// +public static class LegacyFormat +{ + #region Point / Size + /// "336, 1064" 형식 파싱 — 실패 시 (0,0) + public static (double X, double Y) ParsePoint(string? text) + { + if (TryParsePair(text, out var x, out var y)) + { + return (x, y); + } + return (0, 0); + } + + /// "174, 18" 형식 파싱 — 실패 시 (0,0) + public static (double W, double H) ParseSize(string? text) => ParsePoint(text); + + /// Point/Size invariant 문자열 생성 — 레거시와 동일한 "X, Y" (정수 반올림) + 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 + /// + /// 레거시 FontConverter invariant 문자열 파싱 — "굴림, 11.25pt, style=Bold, Underline". + /// 실패 시 기본(굴림 9pt) 반환. + /// + 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; + } + + /// 레거시 FontConverter invariant 형식으로 생성 — "이름, 11.25pt[, style=Bold, Italic]" + public static string FormatFont(LegacyFont font) + { + var size = font.SizePt.ToString("0.##", CultureInfo.InvariantCulture); + var styles = new List(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 + /// + /// 레거시 ColorConverter invariant 문자열 파싱 — "224, 224, 224" / "255, 0, 0, 0"(ARGB) / "White" / "ControlDarkDark". + /// 반환: (A,R,G,B). 이름 색은 System.Drawing 으로 해석, 실패 시 검정. + /// + 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); + } + + /// ColorConverter invariant 형식으로 생성 — 불투명 색은 "R, G, B", 반투명은 "A, R, G, B" + 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 +} + +/// 레거시 폰트 표현 — WinForms Font invariant 문자열과 1:1 +public sealed class LegacyFont +{ + /// 글꼴 패밀리명 + public string Family { get; set; } = "굴림"; + + /// 크기(pt) + public double SizePt { get; set; } = 9; + + /// 굵게 + public bool Bold { get; set; } + + /// 기울임 + public bool Italic { get; set; } + + /// 밑줄 + public bool Underline { get; set; } + + /// 취소선 + public bool Strikeout { get; set; } +} diff --git a/src/SheetMe.Core/Serialization/LegacyTypeCatalog.cs b/src/SheetMe.Core/Serialization/LegacyTypeCatalog.cs new file mode 100644 index 0000000..0dba75e --- /dev/null +++ b/src/SheetMe.Core/Serialization/LegacyTypeCatalog.cs @@ -0,0 +1,102 @@ +namespace SheetMe.Core.Serialization; + +/// +/// 레거시 컨트롤 타입(AQN) ↔ 중립 타입명 매핑의 단일 출처. +/// 읽기: AQN 의 클래스 단축명을 정확 일치로 매핑(미지 타입은 "Placeholder"). +/// 쓰기: 신규 생성 컨트롤의 AQN 을 실DB 샘플에서 확인된 원문 형식으로 생성. +/// +public static class LegacyTypeCatalog +{ + #region Member Fields + /// 미지원/미지 타입의 중립 타입명 + public const string PlaceholderType = "Placeholder"; + + /// 페이지 루트 클래스 단축명 + public const string HostClassName = "MDesignerHost"; + + /// 페이지 루트 AQN (실샘플 sheetdesign.xml 원문 확인) + 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 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 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 + /// AQN 에서 클래스 단축명 추출 (예: "M.EMR.UserControl.TextBox, ..." → "TextBox") + 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(); + } + + /// AQN 을 중립 타입명으로 해석 — 미지 타입은 Placeholder + public static string Resolve(string aqn) + { + var shortName = ShortClassName(aqn); + return legacyToNeutral.TryGetValue(shortName, out var neutral) ? neutral : PlaceholderType; + } + + /// 페이지 루트(MDesignerHost) 타입인지 판정 + public static bool IsHost(string aqn) => ShortClassName(aqn) == HostClassName; + + /// 중립 타입명의 신규 생성용 AQN — 미지원 타입이면 null + 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 +} diff --git a/src/SheetMe.Core/Serialization/LegacyXmlSerializer.cs b/src/SheetMe.Core/Serialization/LegacyXmlSerializer.cs new file mode 100644 index 0000000..12c6939 --- /dev/null +++ b/src/SheetMe.Core/Serialization/LegacyXmlSerializer.cs @@ -0,0 +1,384 @@ +using System.Text; +using System.Xml; +using SheetMe.Core.Models; + +namespace SheetMe.Core.Serialization; + +/// +/// 레거시 서식 XML 직렬화기 — v1 주 포맷. +/// 포맷 원본: [003]DesignerHosting\clsBasicDesignerLoader.vb (읽기 LoadObject/ReadProperty·쓰기 WriteObjectDesignInfo), +/// 병합 규약: clsCommonLib.Xml_MergeXmlDocument (Sheet 루트 1개에 페이지당 MDesignerHost Object N개). +/// 모든 Property 를 PropBag 으로 무손실 왕복하며, 쓰기 시 Bounds 를 Location/Size/LocationOnBase 로 동기화한다. +/// +public sealed class LegacyXmlSerializer : IDesignSerializer +{ + #region Methods - Read + /// 레거시 XML 을 서식 문서로 역직렬화 — 파싱 경고는 document.Meta.ReadWarnings 에 수집 + 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; + } + + /// 빈 페이지 생성 — 레거시 기본 신규 서식(MDesignerHost 720×856)과 동일 + 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 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 warnings) + { + var elementChildren = propNode.ChildNodes.OfType().ToList(); + + if (elementChildren.Count == 0) + { + var hasText = propNode.ChildNodes + .Cast() + .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 + /// 서식 문서를 레거시 XML(병합형 — Sheet 루트에 페이지당 Object)로 직렬화 + 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(); + } + + /// 관용 파서 — 문자 검사 없이 XmlDocument 로드(레거시 XmlTextReader 동등) + 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; + } + + /// + /// Bounds → Props(Location/Size/LocationOnBase/Name) 동기화 — 레거시 writer 의 재계산 동작과 동일. + /// XML 에서 읽어온 컨트롤(LegacyAqn 보유)은 있던 키만 갱신(순수 보존), + /// 신규 생성 컨트롤(LegacyAqn 없음)에는 레거시 필수 속성(LocationOnBase/Name — 실샘플 117/117 보유)을 추가한다. + /// + 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 +} diff --git a/src/SheetMe.Core/Serialization/XmlSemanticDiff.cs b/src/SheetMe.Core/Serialization/XmlSemanticDiff.cs new file mode 100644 index 0000000..8526435 --- /dev/null +++ b/src/SheetMe.Core/Serialization/XmlSemanticDiff.cs @@ -0,0 +1,111 @@ +using System.Text; +using System.Xml; + +namespace SheetMe.Core.Serialization; + +/// +/// XML 의미론 비교 — 레거시 로더가 보는 의미가 같은지 검사. +/// 요소명·어트리뷰트·자식 요소 순서·텍스트를 비교하고, 빈 요소의 self-closing 여부와 요소 사이 공백은 무시한다. +/// (왕복 테스트와 실DB 일괄 스모크가 공유) +/// +public static class XmlSemanticDiff +{ + #region Methods + /// 두 XML 문자열의 의미 차이 목록 — 완전 동일하면 빈 목록 (제어문자 관용 파싱) + public static List Compare(string expectedXml, string actualXml, int maxDiffs = 30) + { + var expected = LegacyXmlSerializer.LoadLenient(expectedXml); + var actual = LegacyXmlSerializer.LoadLenient(actualXml); + + var diffs = new List(); + CompareElements(expected.DocumentElement!, actual.DocumentElement!, "/", diffs, maxDiffs); + return diffs; + } + + private static void CompareElements(XmlElement expected, XmlElement actual, string path, List 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().ToDictionary(a => a.Name, a => a.Value); + var actualAttrs = actual.Attributes.Cast().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().ToList(); + var actualElements = actual.ChildNodes.OfType().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 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 +} diff --git a/src/SheetMe.Core/SheetMe.Core.csproj b/src/SheetMe.Core/SheetMe.Core.csproj new file mode 100644 index 0000000..b29eb6d --- /dev/null +++ b/src/SheetMe.Core/SheetMe.Core.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + SheetMe.Core + SheetMe.Core + + + + + + + + diff --git a/src/SheetMe.Data/Config/DataConfig.cs b/src/SheetMe.Data/Config/DataConfig.cs new file mode 100644 index 0000000..d7b7d40 --- /dev/null +++ b/src/SheetMe.Data/Config/DataConfig.cs @@ -0,0 +1,28 @@ +namespace SheetMe.Data.Config; + +/// +/// 데이터 계층 설정 — appsettings.json 바인딩 모델. +/// SaveMode 기본값은 File — 운영 레거시 테이블(E_SdgMst/E_SctMst) 쓰기는 명시적 설정 후에만 활성화된다. +/// +public sealed class DataConfig +{ + #region Properties + /// Oracle 접속 문자열 (ConnectionStrings:His) + public string ConnectionString { get; set; } = string.Empty; + + /// DB 프로바이더 — 기본 Oracle + public string Provider { get; set; } = "Oracle"; + + /// 저장 모드 — File(기본, .xml 파일) | Db(E_SdgMst/E_SctMst 저장) + public string SaveMode { get; set; } = "File"; + + /// 파일 모드 서식 폴더 + public string XmlFolder { get; set; } = "forms"; + #endregion + + #region Methods + /// DB 저장 모드 여부 + public bool IsDbSaveMode() + => string.Equals(SaveMode, "Db", StringComparison.OrdinalIgnoreCase); + #endregion +} diff --git a/src/SheetMe.Data/DataContexts/EmrDataContext.cs b/src/SheetMe.Data/DataContexts/EmrDataContext.cs new file mode 100644 index 0000000..8a5d0aa --- /dev/null +++ b/src/SheetMe.Data/DataContexts/EmrDataContext.cs @@ -0,0 +1,31 @@ +using M.Framework.TableFramework; +using M.MW.Data.EMR; + +namespace SheetMe.Data.DataContexts; + +/// +/// EMR 서식 마스터 데이터 컨텍스트 — M.MW.Data.EMR 기존 엔티티 재사용(레거시 테이블과 1:1). +/// 목록성 LINQ 조회 기반. CLOB/채번/트랜잭션 저장은 raw ODP.NET 스토어가 담당([200]SheetMe 검증 분업). +/// +public sealed class EmrDataContext : AbstractDataContext +{ + #region Properties + /// 서식 마스터 (E_ShtMst) + public DataSet SheetMasters { get; } + + /// 서식 디자인 버전 (E_SdgMst) + public DataSet SheetDesigns { get; } + + /// 서식 컨트롤 마스터 (E_SctMst) + public DataSet SheetControls { get; } + #endregion + + #region Constructors + public EmrDataContext(string provider, string connectionString) : base(provider, connectionString) + { + SheetMasters = new(access); + SheetDesigns = new(access); + SheetControls = new(access); + } + #endregion +} diff --git a/src/SheetMe.Data/SheetMe.Data.csproj b/src/SheetMe.Data/SheetMe.Data.csproj new file mode 100644 index 0000000..f448341 --- /dev/null +++ b/src/SheetMe.Data/SheetMe.Data.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + SheetMe.Data + SheetMe.Data + + + + + + + + + + + + + + + diff --git a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs new file mode 100644 index 0000000..d3b1fc8 --- /dev/null +++ b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs @@ -0,0 +1,426 @@ +using Oracle.ManagedDataAccess.Client; +using SheetMe.Core.Models; +using SheetMe.Core.Serialization; + +namespace SheetMe.Data.Stores; + +/// 서식 목록 1건(열기 대화상자) +public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign); + +/// 디자인 버전 1건 +public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string UpdUid, bool Deleted); + +/// +/// 레거시 테이블(E_ShtMst/E_SdgMst/E_SctMst) 기반 서식 저장소 — 레거시 디자이너와 동일 저장 의미론. +/// 저장 흐름(bzSaveSheetDesignNControlInfo 이식, 단일 트랜잭션): +/// ShtCneYon='Y'+활성행 존재 → 제자리 갱신(E_SctMst 는 신규 이름만 추가), +/// 그 외 → 기존 활성행 SdgDelYon='Y' 후 신규 행 INSERT + E_SctMst 전체 재생성. +/// 채번: 전용 시퀀스 NEXTVAL 우선, 부재 시 MAX+1 폴백(트랜잭션 내 — [200]SheetMe 검증 방식). +/// +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 - 조회 + /// 서식 목록 검색 — 공백제거 UPPER LIKE 토큰 AND 매칭, 스캔서식(ShtTyp='S') 제외 + public List 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(); + 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; + } + + /// 활성(비삭제 최신) 디자인 로드 — 없으면 null. FormId/Title/SourceSdgKey 채움 + 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; + } + + /// 활성 디자인 원문 XML — (SdgKey, XML), 없으면 null (일괄 왕복 스모크용) + 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; + } + + /// 디자인 버전의 E_SctMst 행 수(검증용) + 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))!); + } + + /// 서식의 제자리 갱신 여부(ShtCneYon) + 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 ?? " "; + } + + /// 특정 버전(SdgKey) 디자인 로드 — 이력 열람/복원용. 없으면 null + 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; + } + + /// + /// 서식의 Spread 디자인들(E_SpdMst) — 컨트롤명(SpdName) → FarPoint XML. + /// SpdShtCod 는 CHAR 패딩 컬럼이라 TRIM 비교(실측). + /// + public Dictionary LoadSpreadDesigns(string shtCod) + { + var result = new Dictionary(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; + } + + /// 디자인 버전 이력(본문 제외) + public List 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(); + 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 - 저장 + /// + /// 디자인 저장 — 레거시 동일 의미론(제자리 갱신 vs 버저닝), 단일 트랜잭션. + /// 반환: 저장된 SdgKey. + /// + 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; + } + } + + /// E_ShtMst 존재 여부 + 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; + } + + /// + /// 신규 서식 최소 등록 — 실DB 디자이너 서식 관행값(ShtTyp='D', 사용/디자인/컨트롤/EMR 'Y', 열람권한 'A' 등). + /// 상세 속성(권한/출력 등)은 레거시 기록지정보 마스터에서 후속 설정. + /// + 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? existingNames = null; + if (updateInPlace) + { + existingNames = new HashSet(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; + } + + /// 활성(비삭제 최신) SdgKey — 없으면 0 ([200] SdgKeyResolver 의미론) + 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; + + /// 채번 — 전용 시퀀스 NEXTVAL 우선, 시퀀스 부재(ORA-02289/00942) 시 MAX+1 폴백 + 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 +} diff --git a/src/SheetMe.Data/Stores/RecordWordStore.cs b/src/SheetMe.Data/Stores/RecordWordStore.cs new file mode 100644 index 0000000..34e2fe1 --- /dev/null +++ b/src/SheetMe.Data/Stores/RecordWordStore.cs @@ -0,0 +1,200 @@ +using Oracle.ManagedDataAccess.Client; + +namespace SheetMe.Data.Stores; + +/// 상용구 1건 +public sealed record RecordWordInfo(decimal Key, string Value, decimal DisplaySeq); + +/// +/// 서식별 상용구(E_SHTWRDMST) 저장소 — 1단계(SHTSTPLVL=1) 문구 CRUD. +/// 런타임 팝업(MRecordWord.dtRecWordMst) 조회 쿼리와 호환: SHTCOD+SHTSTPLVL=1, SHTDSPSEQ 정렬, 루트 SHTPARKEY=0. +/// 레거시에는 등록 UI 가 없어(INSERT 코드 부재 — DB 직접 관리) 본 디자이너가 관리 도구 역할을 겸한다. +/// +public sealed class RecordWordStore +{ + #region Member Fields + private readonly string connectionString; + #endregion + + #region Constructors + public RecordWordStore(string connectionString) + { + this.connectionString = connectionString; + } + #endregion + + #region Methods + /// 서식의 1단계 상용구 목록(표시순) + public List 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(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + result.Add(new RecordWordInfo(reader.GetDecimal(0), reader.GetString(1), reader.GetDecimal(2))); + } + return result; + } + + /// + /// 상용구 추가 — 키는 트리거 자동채번 우선(RETURNING 회수, 실측: 지정 키를 트리거가 덮어씀), + /// 트리거 부재(ORA-01400) 시 MAX+1 명시 폴백. 반환: 실제 저장된 키. + /// + 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; + } + } + + /// 상용구 문구 수정 + 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(); + } + + /// 상용구 삭제(하위 단계 포함) + 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; + } + } + + /// 표시 순서 일괄 갱신 — 목록 순서대로 1..N + public void Reorder(IReadOnlyList 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; + } + + /// SHTUPDDTF 17자리(yyyyMMddHHmmssfff) + 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 +} diff --git a/src/SheetMe.Data/Stores/SctMstXmlWalker.cs b/src/SheetMe.Data/Stores/SctMstXmlWalker.cs new file mode 100644 index 0000000..7a13ee1 --- /dev/null +++ b/src/SheetMe.Data/Stores/SctMstXmlWalker.cs @@ -0,0 +1,150 @@ +using System.Xml; + +namespace SheetMe.Data.Stores; + +/// E_SctMst 저장 행 1건 — XML 트리 순회 산출물 +public sealed class SctRow +{ + /// 컨트롤 이름(SctObjNam) + public string ObjName { get; init; } = string.Empty; + + /// 계층 알파벳 ID(SctObjID) — 15자 우측 공백 패딩 + public string ObjId { get; init; } = string.Empty; + + /// 타입 풀네임(SctObjTyp) — AQN 의 첫 콤마 앞 + public string ObjType { get; init; } = string.Empty; + + /// 부모 알파벳 ID(SctParObj) — 15자 우측 공백 패딩 + public string ParentId { get; init; } = string.Empty; + + /// 표시 순서(SctObjSeq) — DisplaySequence 어트리뷰트 + public string ObjSeq { get; init; } = string.Empty; + + /// 텍스트(SctObjTxt) — Text Property 원문 + public string ObjText { get; init; } = string.Empty; +} + +/// +/// 서식 XML → E_SctMst 행 목록 변환 — 레거시 bzSaveSheetDesignNControlInfo.SaveE_SctMst 의 순수 이식. +/// 모델이 아닌 '최종 직렬화된 XML'을 순회해 100% 패리티(Spread AsTemplate 특례 포함)를 보장한다. +/// +public static class SctMstXmlWalker +{ + #region Methods + /// 병합 XML 의 전체 Object 트리를 E_SctMst 행 목록으로 (루트 MDesignerHost 포함 — 레거시 규약) + public static List Walk(string mergedXml) + { + var rows = new List(); + 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; + } + + /// 알파벳 시퀀스 증가 — A→B…Z→AA→AB…, 전부 Z 면 자릿수 확장(clsCommonLib 이식) + 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 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 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 +} diff --git a/src/SheetMe.Data/Stores/XmlFileFormStore.cs b/src/SheetMe.Data/Stores/XmlFileFormStore.cs new file mode 100644 index 0000000..fab436e --- /dev/null +++ b/src/SheetMe.Data/Stores/XmlFileFormStore.cs @@ -0,0 +1,35 @@ +using System.Text; +using SheetMe.Core.Models; +using SheetMe.Core.Serialization; + +namespace SheetMe.Data.Stores; + +/// +/// 파일 기반 서식 저장소 — 레거시 XML(.xml) 파일 열기/저장. +/// 레거시 디자이너의 XML 내보내기(TK_ExportXML) 산출물과 호환되며, DB 없는 개발 모드의 기본 저장소. +/// +public sealed class XmlFileFormStore +{ + #region Member Fields + private readonly LegacyXmlSerializer serializer = new(); + #endregion + + #region Methods + /// XML 파일 열기 — FormId 는 파일명에서 유래 + 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; + } + + /// XML 파일 저장 — UTF-8(BOM 없음) + public void SaveFile(FormDocument document, string path) + { + var xml = serializer.Write(document); + File.WriteAllText(path, xml, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + #endregion +} diff --git a/src/SheetMe.Designer/App.xaml b/src/SheetMe.Designer/App.xaml new file mode 100644 index 0000000..7f0295d --- /dev/null +++ b/src/SheetMe.Designer/App.xaml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs new file mode 100644 index 0000000..441caf4 --- /dev/null +++ b/src/SheetMe.Designer/App.xaml.cs @@ -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; + +/// +/// 서식생성기 애플리케이션 진입점. +/// 명령행: --render-smoke <입력.xml> <출력.png> — 첫 페이지를 오프스크린 렌더해 PNG 저장(검증용) 후 종료. +/// +public partial class App : Application +{ + /// 기동 — 스모크 모드 분기 또는 메인 셸 표시 + 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(); + } + + /// 첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증) + 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; + } + } + + /// DB 서식 첫 페이지 오프스크린 렌더 → PNG (Spread 격자 주입 경로 포함 검증) + 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; + } + } +} diff --git a/src/SheetMe.Designer/Assets/sheetme-logo.png b/src/SheetMe.Designer/Assets/sheetme-logo.png new file mode 100644 index 0000000..a282eda Binary files /dev/null and b/src/SheetMe.Designer/Assets/sheetme-logo.png differ diff --git a/src/SheetMe.Designer/Assets/sheetme.ico b/src/SheetMe.Designer/Assets/sheetme.ico new file mode 100644 index 0000000..1dc66ea Binary files /dev/null and b/src/SheetMe.Designer/Assets/sheetme.ico differ diff --git a/src/SheetMe.Designer/Behaviors/CanvasDropBehavior.cs b/src/SheetMe.Designer/Behaviors/CanvasDropBehavior.cs new file mode 100644 index 0000000..61306ec --- /dev/null +++ b/src/SheetMe.Designer/Behaviors/CanvasDropBehavior.cs @@ -0,0 +1,48 @@ +using System.Windows; +using Microsoft.Xaml.Behaviors; +using SheetMe.Designer.ViewModels; + +namespace SheetMe.Designer.Behaviors; + +/// 캔버스 드롭 수신 — 팔레트 타입을 월드 좌표에 배치. +public sealed class CanvasDropBehavior : Behavior +{ + #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 +} diff --git a/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs b/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs new file mode 100644 index 0000000..8b7cbfd --- /dev/null +++ b/src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs @@ -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; + +/// +/// 캔버스 키보드 단축키 — 텍스트 입력 요소에 포커스가 있으면 통과(입력 보호). +/// 화살표 넛지(Shift=그리드), Del, Ctrl+A/C/V/D/Z/Y, Esc. +/// +public sealed class CanvasKeyboardBehavior : Behavior +{ + #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 +} diff --git a/src/SheetMe.Designer/Behaviors/CanvasMouseBehavior.cs b/src/SheetMe.Designer/Behaviors/CanvasMouseBehavior.cs new file mode 100644 index 0000000..1e9e1eb --- /dev/null +++ b/src/SheetMe.Designer/Behaviors/CanvasMouseBehavior.cs @@ -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; + +/// +/// 캔버스 마우스 입력 → 월드 좌표 → InteractionController 위임. +/// World 요소(LayoutTransform 줌 하위)에 부착 — GetPosition(World)가 줌 불변 논리좌표를 반환. +/// 로직 없음: 좌표 변환·캡처·커서 매핑만 담당. +/// +public sealed class CanvasMouseBehavior : Behavior +{ + #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 +} diff --git a/src/SheetMe.Designer/Behaviors/PaletteDragBehavior.cs b/src/SheetMe.Designer/Behaviors/PaletteDragBehavior.cs new file mode 100644 index 0000000..863466d --- /dev/null +++ b/src/SheetMe.Designer/Behaviors/PaletteDragBehavior.cs @@ -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; + +/// 팔레트 항목 드래그 시작 — 5px 임계 후 DoDragDrop("SheetMe.ControlType"). +public sealed class PaletteDragBehavior : Behavior +{ + #region Member Fields + /// 드래그 데이터 포맷 키 + 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(); + } + + /// 더블클릭 즉시 배치 — 레거시 툴박스 UX(활성 페이지 중앙 부근) + 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 +} diff --git a/src/SheetMe.Designer/Controls/ControlTemplates.xaml b/src/SheetMe.Designer/Controls/ControlTemplates.xaml new file mode 100644 index 0000000..d47c3da --- /dev/null +++ b/src/SheetMe.Designer/Controls/ControlTemplates.xaml @@ -0,0 +1,375 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SheetMe.Designer/Controls/LucideIcons.cs b/src/SheetMe.Designer/Controls/LucideIcons.cs new file mode 100644 index 0000000..fb2db1c --- /dev/null +++ b/src/SheetMe.Designer/Controls/LucideIcons.cs @@ -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; + +/// +/// Lucide(ISC) 아이콘을 WPF 로 렌더 — SVG(24x24, stroke 2, round) inner 마크업을 +/// Geometry 로 파싱해 Viewbox+Path 로 그린다. 외부 패키지 불필요. +/// +public static class LucideIcons +{ + private static readonly Dictionary Data = new(StringComparer.Ordinal) + { + ["file-text"] = """ """, + ["layout-dashboard"] = """ """, + ["pencil-ruler"] = """ """, + ["settings"] = """ """, + ["eye"] = """ """, + ["save"] = """ """, + ["folder-open"] = """""", + ["plus"] = """ """, + ["trash-2"] = """ """, + ["link"] = """ """, + ["sigma"] = """""", + ["table"] = """ """, + ["printer"] = """ """, + ["pen-line"] = """ """, + ["building-2"] = """ """, + ["package"] = """ """, + ["key-round"] = """ """, + ["scroll-text"] = """ """, + ["search"] = """ """, + ["circle-check"] = """ """, + ["type"] = """ """, + ["text-cursor-input"] = """ """, + ["calendar"] = """ """, + ["list"] = """ """, + ["square-check"] = """ """, + ["circle-dot"] = """ """, + ["heading"] = """ """, + ["check"] = """""", + ["chevron-right"] = """""", + ["chevron-left"] = """""", + ["chevron-down"] = """""", + ["layout-template"] = """ """, + ["download"] = """ """, + ["pencil"] = """ """, + ["folder"] = """""", + ["minus"] = """""", + ["image"] = """ """, + ["mouse-pointer-click"] = """ """, + ["list-checks"] = """ """, + ["box"] = """ """, + ["align-left"] = """ """, + ["align-center"] = """ """, + ["align-right"] = """ """, + ["bold"] = """ """, + ["italic"] = """ """, + ["underline"] = """ """, + ["zoom-in"] = """ """, + ["zoom-out"] = """ """, + ["maximize"] = """ """, + ["eye-off"] = """ """, + ["copy"] = """ """, + ["clipboard"] = """ """, + ["undo-2"] = """ """, + ["redo-2"] = """ """, + ["sun"] = """ """, + ["moon"] = """""", + ["move-horizontal"] = """ """, + ["move-vertical"] = """ """, + ["align-obj-top"] = """ """, + ["align-obj-middle"] = """ """, + ["align-obj-bottom"] = """ """, + ["distribute-h"] = """ """, + ["distribute-v"] = """ """, + ["square"] = """""", + ["lock"] = """ """, + ["unlock"] = """ """, + ["group"] = """ """, + ["ungroup"] = """ """, + ["align-obj-left"] = """ """, + ["align-obj-center-h"] = """ """, + ["align-obj-right"] = """ """, + ["droplet"] = """""", + ["rotate-cw"] = """ """, + ["frame"] = """ """, + ["filter"] = """""", + ["filter-x"] = """ """, + ["user"] = """ """, + ["play"] = """""", + ["menu"] = """ """, + ["hand"] = """ """, + ["layout-grid"] = """ """, + }; + + /// 아이콘 + 텍스트 가로 묶음 (버튼/네비 Content 용). + 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()); + } +} diff --git a/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs b/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs new file mode 100644 index 0000000..7eb9248 --- /dev/null +++ b/src/SheetMe.Designer/Controls/PaletteIconCatalog.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace SheetMe.Designer.Controls; + +/// +/// 팔레트 타입 → (Lucide 아이콘, 카테고리) 매핑 — [200]SheetMe 팔레트 타일 룩 이식. +/// Core(ControlRegistry)는 UI 무관하게 유지하고 표시 계층인 여기서만 매핑한다. +/// +public static class PaletteIconCatalog +{ + #region Member Fields + private static readonly Dictionary 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 + /// 타입의 Lucide 아이콘명(미지정 타입은 square) + public static string IconOf(string type) => Map.TryGetValue(type, out var entry) ? entry.Icon : "square"; + + /// 타입의 팔레트 카테고리(그룹 헤더) + public static string CategoryOf(string type) => Map.TryGetValue(type, out var entry) ? entry.Category : "기타"; + #endregion +} + +/// 팔레트 타입명 → Lucide 아이콘 비주얼(FrameworkElement) 컨버터. parameter=크기(기본 18) +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(); +} + +/// 팔레트 타입명 → 카테고리명 컨버터(CollectionViewSource 그룹핑용) +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(); +} + +/// 불리언 반전 컨버터(선택 도구 토글 = !IsHandTool 표시용) +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; +} + +/// +/// Lucide 아이콘명 → 비주얼 컨버터(문서 탭 file-text, 플로팅 바 layout-grid 등 고정 아이콘용). +/// Binding Source 에 아이콘명 문자열을 넣어 사용 — 항목마다 새 비주얼이 생성되어 공유 부모 충돌이 없다. +/// parameter=크기(기본 14). +/// +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(); +} diff --git a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs new file mode 100644 index 0000000..0bb3bee --- /dev/null +++ b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs @@ -0,0 +1,117 @@ +using SheetMe.Core.Models; +using SheetMe.Data.Config; +using SheetMe.Data.Stores; +using SheetMe.Designer.Services; + +namespace SheetMe.Designer.DataBusiness; + +/// +/// 서식 디자인 데이터 비즈니스 — 저장소(파일/DB) 접근을 ViewModel 에서 격리. +/// 본 도구는 내부 관리용 팻 클라이언트로 저장소를 직접 호출한다(컨벤션 편차 — 계획서 명시). +/// DB 저장은 SaveMode='Db' 설정 게이트 뒤에서만 활성화. REST 전환 시 이 계층만 교체한다. +/// +internal sealed class FormDesignDataBusiness +{ + #region Member Fields + private readonly XmlFileFormStore fileStore = new(); + private OracleLegacyFormStore? dbStore; + #endregion + + #region Properties + /// 데이터 설정(appsettings.json) + public DataConfig Config { get; } = ConfigLoader.Load(); + + /// DB 사용 가능 여부 — 접속 문자열 존재 + public bool CanUseDb => Config.ConnectionString.Length > 0; + + /// DB 저장 허용 여부 — SaveMode='Db' 명시 설정 + public bool CanSaveToDb => CanUseDb && Config.IsDbSaveMode(); + #endregion + + #region Methods + /// 레거시 XML 파일 열기 + public FormDocument OpenXmlFile(string path) => fileStore.LoadFile(path); + + /// 레거시 XML 파일 저장 + public void SaveXmlFile(FormDocument document, string path) => fileStore.SaveFile(document, path); + + /// 새 문서 생성 — 빈 페이지 1장(레거시 기본 720×856) + public FormDocument CreateNew() + { + var document = new FormDocument + { + FormId = "NewSheet", + Title = "새 서식", + }; + document.Pages.Add(Core.Serialization.LegacyXmlSerializer.CreateEmptyPage(1)); + return document; + } + + /// DB 서식 목록 검색 + public List ListSheets(string? keyword) + => DbStore().ListSheets(keyword); + + /// DB 활성 디자인 열기 — 디자인 없으면 null + public FormDocument? OpenFromDb(string shtCod) + => DbStore().LoadActiveDesign(shtCod); + + /// 서식 디자인 버전 이력(E_SdgMst, 본문 제외) + public List ListVersions(string shtCod) + => DbStore().ListVersions(shtCod); + + /// 특정 버전 디자인 열기 — 이력 열람/복원 + public FormDocument? OpenFromDbVersion(string shtCod, decimal sdgKey) + => DbStore().LoadDesignByKey(shtCod, sdgKey); + + /// 서식의 Spread 격자(E_SpdMst 파싱) — 컨트롤명 → 격자 정보(파싱 실패 건 제외) + public Dictionary LoadSpreadGrids(string shtCod) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var (name, xml) in DbStore().LoadSpreadDesigns(shtCod)) + { + if (Core.Serialization.FarPointSpreadParser.Parse(xml) is { } grid) + { + result[name] = grid; + } + } + return result; + } + + /// DB 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — 반환 SdgKey + public decimal SaveToDb(FormDocument document) + { + if (!CanSaveToDb) + { + throw new InvalidOperationException( + "DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요."); + } + return DbStore().SaveDesign(document, Environment.UserName); + } + + /// 서식 코드 등록 여부(E_ShtMst) + public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod); + + /// 신규 서식 최소 등록 — 디자이너 서식 관행 기본값 + public void RegisterSheet(string shtCod, string korName, string? clsCod) + => DbStore().RegisterSheet(shtCod, korName, clsCod, Environment.UserName); + + /// 상용구 저장소(E_SHTWRDMST) — DB 접속 필요 + 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 +} diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs new file mode 100644 index 0000000..cdbb6d1 --- /dev/null +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -0,0 +1,393 @@ +using System.IO; +using SheetMe.Core.Serialization; +using SheetMe.Data.Stores; +using SheetMe.Designer.Services; + +namespace SheetMe.Designer.Diagnostics; + +/// +/// 실DB 검증 진단. +/// --db-smoke <리포트> [N] : 읽기 전용 — 활성 디자인 N건 일괄 Read→Write 의미론 diff 통계. +/// --db-save-smoke <서식코드> <리포트> : 쓰기 — 무변경 재저장 후 재로드 비교 + E_SctMst 행 수 검증. +/// +public static class DbSmoke +{ + #region Methods + /// 읽기 전용 일괄 왕복 스모크 + public static int RunReadSmoke(string reportPath, int maxSheets) + { + var lines = new List(); + 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(); + var failed = new List(); + var placeholderTypes = new Dictionary(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; + } + } + + /// 테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용) + 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(); + 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; + } + } + + /// 서식 마스터 행 주요 플래그 조회(--db-row) — 신규 등록 기본값 참조용(읽기 전용) + 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; + } + } + + /// 테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용 + 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(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var parts = new List(); + 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; + } + } + + /// Spread 디자인 조회(--db-spd) — 목록 또는 특정 건 원문 덤프(읽기 전용) + 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(); + 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; + } + } + + /// 활성 디자인 XML 원문 덤프(--db-xml) — 읽기 전용 + 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; + } + } + + /// 서식 검색(--db-find) — 키워드 목록 출력 + 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; + } + } + + /// 상용구(E_SHTWRDMST) CRUD 왕복 스모크(--db-word-smoke) — 등록→조회→수정→순서→삭제(흔적 없음) + public static int RunWordSmoke(string shtCod, string reportPath) + { + var lines = new List(); + 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; + } + } + + /// 무변경 재저장 스모크 — 저장 경로 전체(트랜잭션/버저닝/E_SctMst) 검증 + public static int RunSaveSmoke(string shtCod, string reportPath) + { + var lines = new List(); + 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 +} diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs new file mode 100644 index 0000000..c98959a --- /dev/null +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -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; + +/// +/// 편집 코어 자동 검증(--edit-smoke) — 실제 마우스 없이 InteractionController 에 포인터 시퀀스를 주입해 +/// 선택/이동/리사이즈/마퀴/복사/붙여넣기/삭제/Undo/저장왕복을 검사한다. +/// +public static class EditSmoke +{ + #region Methods + /// 스모크 실행 — 결과를 리포트 파일에 기록, 실패 있으면 1 반환 + public static int Run(string reportPath) + { + var lines = new List(); + 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("") + && xmlA.Contains("")); + + // 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() + .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() + .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().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().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().FirstOrDefault(); + Check("고급: 토글 행 존재", toggleRow is not null); + toggleRow!.ToggleCommand!.Execute(null, EventArgs.Empty); + var advRows = designer.Inspector.Rows + .OfType() + .Where(r => r.Label is "LocationOnBase" or "TabIndex" or "DisplaySequence") + .ToList(); + Check("고급: raw 속성 행 노출", designer.Inspector.Rows + .OfType() + .Any(r => r.Label.StartsWith("전체 속성"))); + + var addRow = designer.Inspector.Rows + .OfType().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() + .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 +} diff --git a/src/SheetMe.Designer/Services/ConfigLoader.cs b/src/SheetMe.Designer/Services/ConfigLoader.cs new file mode 100644 index 0000000..fdeb211 --- /dev/null +++ b/src/SheetMe.Designer/Services/ConfigLoader.cs @@ -0,0 +1,44 @@ +using System.IO; +using Microsoft.Extensions.Configuration; +using SheetMe.Data.Config; + +namespace SheetMe.Designer.Services; + +/// +/// appsettings → DataConfig 바인딩 로더. +/// 우선순위: 환경변수 > appsettings.Development.json > appsettings.json. +/// 커밋되는 appsettings.json 은 __HOST__ 류 플레이스홀더만 담으며, 실접속 정보는 +/// appsettings.Development.json(개발, Debug 빌드에서만 산출물 복사) 또는 환경변수로 주입한다. +/// +public static class ConfigLoader +{ + #region Methods + /// 설정 로드 — 설정이 없으면 기본값(File 모드) + 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; + } + + /// + /// 커밋본의 미치환 플레이스홀더인지 — 이 경우 '미설정'으로 간주해 DB 기능을 끈다. + /// 플레이스홀더를 그대로 접속에 쓰면 무의미한 연결 실패 예외가 사용자에게 노출된다. + /// + private static bool IsPlaceholder(string connectionString) => + connectionString.Length == 0 || connectionString.Contains("__", StringComparison.Ordinal); + #endregion +} diff --git a/src/SheetMe.Designer/Services/DialogService.cs b/src/SheetMe.Designer/Services/DialogService.cs new file mode 100644 index 0000000..a0a0c66 --- /dev/null +++ b/src/SheetMe.Designer/Services/DialogService.cs @@ -0,0 +1,32 @@ +using Microsoft.Win32; + +namespace SheetMe.Designer.Services; + +/// 파일 대화상자 래퍼 — ViewModel 에서 View 기술 의존을 격리. +public sealed class DialogService +{ + #region Methods + /// 열기 대화상자 — 취소 시 null + public string? ShowOpenFile(string filter, string title) + { + var dialog = new OpenFileDialog + { + Filter = filter, + Title = title, + }; + return dialog.ShowDialog() == true ? dialog.FileName : null; + } + + /// 저장 대화상자 — 취소 시 null + 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 +} diff --git a/src/SheetMe.Designer/Services/DocumentMapper.cs b/src/SheetMe.Designer/Services/DocumentMapper.cs new file mode 100644 index 0000000..c9c26e3 --- /dev/null +++ b/src/SheetMe.Designer/Services/DocumentMapper.cs @@ -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; + +/// +/// 모델 ↔ ViewModel 매핑 팩토리. +/// z-order 규약: 모델(=XML Object 순서)은 index 0 이 최상위(WinForms Controls 규약), +/// WPF Canvas 는 나중에 그린 것이 위 — 따라서 VM 컬렉션은 모델의 역순(그리기 순서)이다. +/// +public static class DocumentMapper +{ + #region Methods + /// 페이지 모델 → 페이지 VM (컨트롤 트리 포함) + 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; + } + + /// 컨트롤 모델 → 타입별 VM (자식·시각 컨텍스트 포함) + 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; + } + + /// 모델 순서(0=최상위) → 그리기 순서(마지막=최상위)로 역전 + private static IEnumerable PaintOrder(List models) + => Enumerable.Reverse(models); + #endregion +} diff --git a/src/SheetMe.Designer/Services/IdGenerator.cs b/src/SheetMe.Designer/Services/IdGenerator.cs new file mode 100644 index 0000000..cbd2d72 --- /dev/null +++ b/src/SheetMe.Designer/Services/IdGenerator.cs @@ -0,0 +1,49 @@ +using SheetMe.Core.Models; + +namespace SheetMe.Designer.Services; + +/// 컨트롤 Id 생성 — 문서 전체에서 유일한 "TextBox1" 식 이름 부여(레거시 관행). +public static class IdGenerator +{ + #region Methods + /// 문서의 사용 중 Id 집합 수집 + public static HashSet CollectUsed(FormDocument document) + { + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var page in document.Pages) + { + Collect(page.Root, used); + } + return used; + } + + /// 타입 기반 유일 Id 생성 — 발급한 Id 는 used 집합에 추가된다(연속 발급 안전) + public static string NextId(HashSet used, string type) + { + for (var i = 1; ; i++) + { + var candidate = type + i; + if (used.Add(candidate)) + { + return candidate; + } + } + } + + /// 단건 발급 편의 — 문서 스캔 후 1개 생성 + public static string NextId(FormDocument document, string type) + => NextId(CollectUsed(document), type); + + private static void Collect(ControlElement element, HashSet used) + { + if (element.Id.Length > 0) + { + used.Add(element.Id); + } + foreach (var child in element.Children) + { + Collect(child, used); + } + } + #endregion +} diff --git a/src/SheetMe.Designer/Services/InteractionController.cs b/src/SheetMe.Designer/Services/InteractionController.cs new file mode 100644 index 0000000..6748696 --- /dev/null +++ b/src/SheetMe.Designer/Services/InteractionController.cs @@ -0,0 +1,422 @@ +using System.Windows; +using SheetMe.Designer.ViewModels; +using SheetMe.Designer.ViewModels.Controls; + +namespace SheetMe.Designer.Services; + +/// 포인터 컨텍스트 — 수정키/클릭 수 +public readonly record struct PointerContext(bool Ctrl, bool Shift, bool Alt, int ClickCount); + +/// 커서 종류 — 뷰(Behavior)가 WPF Cursor 로 매핑 +public enum CursorKind +{ + Arrow, + SizeAll, + SizeNWSE, + SizeNESW, + SizeNS, + SizeWE, +} + +/// +/// 마우스 상호작용 상태머신 — 월드 좌표만 다루는 순수 로직(뷰 비의존). +/// 상태: None → PendingMove(임계 대기) → Move / Resize / Marquee. +/// 드래그 = Undo 1스텝(첫 실이동 시 스냅샷), 절대좌표 재계산(드리프트 방지). +/// +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 origBounds = new(); + private bool undoCaptured; + #endregion + + #region Constructors + public InteractionController(DesignerViewModel designer) + { + this.designer = designer; + } + #endregion + + #region Methods - Pointer + /// 마우스 다운(월드 좌표) + 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)); + } + + /// 마우스 이동(월드 좌표) + 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; + } + } + + /// 마우스 업(월드 좌표) + 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(); + } + + /// + /// 진행 중 드래그만 취소(원위치 복원) — 캡처 상실 등 비정상 종료용. + /// 유휴 상태에서는 아무것도 하지 않는다(정상 마우스 업 후 캡처 해제가 선택을 지우면 안 됨). + /// + 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; + } + } + + /// Esc — 탭순서 모드 취소 / 진행 중 드래그 취소 / 유휴 상태면 선택 해제 + public void Cancel() + { + if (designer.IsTabOrderMode) + { + designer.CancelTabOrderMode(); + return; + } + if (mode == Mode.None) + { + designer.Selection.Clear(); + return; + } + CancelDrag(); + } + + /// 커서 판정(호버 피드백) + 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(); + } + + /// 드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝 + private void EnsureUndoSnapshot() + { + if (!undoCaptured) + { + designer.Undo.Snapshot(); + undoCaptured = true; + } + } + + /// 선택 항목의 월드 bbox·개별 원본 bounds 캡처(X 는 페이지 로컬 = 월드 X, Y 는 월드) + 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 +} diff --git a/src/SheetMe.Designer/Services/PrintService.cs b/src/SheetMe.Designer/Services/PrintService.cs new file mode 100644 index 0000000..9ca47c1 --- /dev/null +++ b/src/SheetMe.Designer/Services/PrintService.cs @@ -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; + +/// +/// 인쇄 서비스 — 페이지당 FixedPage 1:1(DIP)로 조립해 인쇄. +/// 캔버스와 동일한 DataTemplate 사전(App 리소스)을 사용하므로 화면=인쇄 렌더가 일치한다. +/// 용지 그림자 등 편집 크롬 없이 흰 배경 + 컨트롤만 그린다. +/// +public static class PrintService +{ + #region Methods + /// 인쇄 대화상자 → 전체 페이지 인쇄 + 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}"); + } + + /// 페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용) + 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; + } + + /// 페이지 컨트롤층 비주얼 — 크롬 없는 Canvas(그리기 순서 = 컬렉션 순서, 미리보기/인쇄 공용) + 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 +} diff --git a/src/SheetMe.Designer/Services/SelectionService.cs b/src/SheetMe.Designer/Services/SelectionService.cs new file mode 100644 index 0000000..c8a1dce --- /dev/null +++ b/src/SheetMe.Designer/Services/SelectionService.cs @@ -0,0 +1,82 @@ +using SheetMe.Designer.ViewModels; +using SheetMe.Designer.ViewModels.Controls; + +namespace SheetMe.Designer.Services; + +/// 선택 집합 관리 — 다중선택 + Primary. 변경 시 IsSelected 플래그 동기화 및 Changed 통지. +public sealed class SelectionService +{ + #region Member Fields + private readonly List items = new(); + #endregion + + #region Properties + /// 선택된 컨트롤 목록 + public IReadOnlyList Items => items; + + /// 기준(Primary) 선택 + public ControlViewModel? Primary { get; private set; } + + /// Primary 가 속한 페이지 — 컨트롤 추가 대상 결정용 + public PageViewModel? ActivePage { get; set; } + #endregion + + #region Events + /// 선택 변경 통지(경계 이동 포함) — 오버레이 구독 + public event Action? Changed; + + /// 선택 '집합' 변경 통지(Set/Toggle/Clear 만) — 인스펙터 재구성 구독(드래그 프레임 제외) + public event Action? SetChanged; + #endregion + + #region Methods + /// 선택 교체 + public void Set(IEnumerable 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(); + } + + /// 단일 선택 + public void SetSingle(ControlViewModel item) => Set(new[] { item }, item); + + /// 토글(Ctrl/Shift+클릭) + 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(); + } + + /// 선택 해제 + public void Clear() => Set(Array.Empty()); + + /// 이동/리사이즈 중 오버레이 갱신 트리거 + public void NotifyBoundsChanged() => Changed?.Invoke(); + #endregion +} diff --git a/src/SheetMe.Designer/Services/SnapEngine.cs b/src/SheetMe.Designer/Services/SnapEngine.cs new file mode 100644 index 0000000..4a62fd9 --- /dev/null +++ b/src/SheetMe.Designer/Services/SnapEngine.cs @@ -0,0 +1,125 @@ +using System.Windows; +using SheetMe.Designer.ViewModels; +using SheetMe.Designer.ViewModels.Controls; + +namespace SheetMe.Designer.Services; + +/// 스냅 결과 — 보정된 이동량과 표시할 가이드 좌표(월드) +public readonly record struct SnapResult(double Dx, double Dy, double? GuideX, double? GuideY); + +/// +/// 그리드/정렬 스냅 순수 계산. +/// 드래그 시작 시 후보(비선택 형제의 좌/중/우·상/중/하 + 용지 경계/중앙)를 1회 사전수집해 +/// 프레임당 재수집을 피한다. +/// +public sealed class SnapEngine +{ + #region Member Fields + private readonly List candidatesX = new(); + private readonly List candidatesY = new(); + #endregion + + #region Properties + /// 그리드 간격(px) + public double GridSize { get; set; } = 4; + + /// 정렬 스냅 허용 거리(px) + public double Tolerance { get; set; } = 6; + #endregion + + #region Methods + /// 드래그 시작 — 스냅 후보 사전수집(월드 좌표) + public void BeginDrag(PageViewModel page, IReadOnlySet 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); + } + } + + /// + /// 이동 스냅 — bbox(월드)에 (dx,dy) 적용 시 정렬 후보와 최근접 정합. + /// free=true(Alt)면 스냅 없이 원본 이동량 그대로. + /// + 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); + } + + /// 리사이즈 스냅 — 움직이는 모서리 좌표만 정합(간이: 그리드 우선) + 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 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 +} diff --git a/src/SheetMe.Designer/Services/ThemeManager.cs b/src/SheetMe.Designer/Services/ThemeManager.cs new file mode 100644 index 0000000..6e0ac71 --- /dev/null +++ b/src/SheetMe.Designer/Services/ThemeManager.cs @@ -0,0 +1,80 @@ +using System.IO; +using System.Text.Json; +using System.Windows; + +namespace SheetMe.Designer.Services; + +/// +/// 라이트/다크 테마 전환 — App.Resources 의 토큰 사전(Tokens.Dark↔Light)을 교체하면 +/// {DynamicResource B.*} 를 참조하는 모든 스타일이 라이브 리스킨된다([200]SheetMe SwapThemeTokens 이식). +/// 선택은 %LocalAppData%\SheetMe\theme.json 에 보존. +/// +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 + /// 현재 라이트 테마 여부(기본 다크 — [200]SheetMe 기본값과 동일) + public static bool IsLight { get; private set; } + #endregion + + #region Methods + /// 시작 시 저장된 테마 적용 + 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 + { + // 설정 손상 시 기본(다크) 유지 + } + } + + /// 라이트↔다크 토글 + public static void Toggle() => Apply(!IsLight); + + /// 테마 적용 — App.Resources 병합 사전에서 토큰 dict 를 찾아 교체 + 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 +} diff --git a/src/SheetMe.Designer/Services/UndoService.cs b/src/SheetMe.Designer/Services/UndoService.cs new file mode 100644 index 0000000..ec85f42 --- /dev/null +++ b/src/SheetMe.Designer/Services/UndoService.cs @@ -0,0 +1,94 @@ +using SheetMe.Core.Models; + +namespace SheetMe.Designer.Services; + +/// +/// 문서 스냅샷(메멘토) Undo/Redo — 직렬화 대신 모델 딥클론(편집 전용 상태 포함) 사용. +/// 규약: 변경 '직전' Snapshot() 1회. 드래그는 첫 실이동에서 1회 = 드래그 전체가 1스텝. +/// 방향키 넛지는 400ms 코얼레스. +/// +public sealed class UndoService +{ + #region Member Fields + private const int Capacity = 100; + private readonly List undoStack = new(); + private readonly List redoStack = new(); + private readonly Func getDocument; + private readonly Action restoreDocument; + private DateTime lastNudgeAt = DateTime.MinValue; + #endregion + + #region Properties + /// Undo 가능 여부 + public bool CanUndo => undoStack.Count > 0; + + /// Redo 가능 여부 + public bool CanRedo => redoStack.Count > 0; + #endregion + + #region Constructors + public UndoService(Func getDocument, Action restoreDocument) + { + this.getDocument = getDocument; + this.restoreDocument = restoreDocument; + } + #endregion + + #region Events + /// 복원 발생 통지(Undo/Redo 실행 후) + public event Action? Restored; + #endregion + + #region Methods + /// 변경 직전 스냅샷 적재 — redo 클리어 + public void Snapshot() + { + undoStack.Add(getDocument().Clone()); + if (undoStack.Count > Capacity) + { + undoStack.RemoveRange(0, undoStack.Count - Capacity); + } + redoStack.Clear(); + lastNudgeAt = DateTime.MinValue; + } + + /// 넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스 + public void SnapshotForNudge() + { + var now = DateTime.UtcNow; + if ((now - lastNudgeAt).TotalMilliseconds > 400) + { + Snapshot(); + } + lastNudgeAt = now; + } + + /// 실행 취소 + public void Undo() + { + if (!CanUndo) + { + return; + } + redoStack.Add(getDocument().Clone()); + var snapshot = undoStack[^1]; + undoStack.RemoveAt(undoStack.Count - 1); + restoreDocument(snapshot); + Restored?.Invoke(); + } + + /// 다시 실행 + public void Redo() + { + if (!CanRedo) + { + return; + } + undoStack.Add(getDocument().Clone()); + var snapshot = redoStack[^1]; + redoStack.RemoveAt(redoStack.Count - 1); + restoreDocument(snapshot); + Restored?.Invoke(); + } + #endregion +} diff --git a/src/SheetMe.Designer/SheetMe.Designer.csproj b/src/SheetMe.Designer/SheetMe.Designer.csproj new file mode 100644 index 0000000..c39294d --- /dev/null +++ b/src/SheetMe.Designer/SheetMe.Designer.csproj @@ -0,0 +1,43 @@ + + + + net10.0-windows + WinExe + true + SheetMe.Designer + SheetMe.Designer + Assets\sheetme.ico + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + PreserveNewest + + + + diff --git a/src/SheetMe.Designer/Themes/DesignerTheme.xaml b/src/SheetMe.Designer/Themes/DesignerTheme.xaml new file mode 100644 index 0000000..82670fe --- /dev/null +++ b/src/SheetMe.Designer/Themes/DesignerTheme.xaml @@ -0,0 +1,806 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SheetMe.Designer/Themes/Tokens.Dark.xaml b/src/SheetMe.Designer/Themes/Tokens.Dark.xaml new file mode 100644 index 0000000..e344cba --- /dev/null +++ b/src/SheetMe.Designer/Themes/Tokens.Dark.xaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SheetMe.Designer/Themes/Tokens.Light.xaml b/src/SheetMe.Designer/Themes/Tokens.Light.xaml new file mode 100644 index 0000000..e1e06ca --- /dev/null +++ b/src/SheetMe.Designer/Themes/Tokens.Light.xaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs b/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs new file mode 100644 index 0000000..e79d033 --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs @@ -0,0 +1,193 @@ +using System.Windows; +using System.Windows.Media; +using SheetMe.Core.Models; +using SheetMe.Core.Serialization; + +namespace SheetMe.Designer.ViewModels.Controls; + +/// +/// 캔버스 컨트롤 ViewModel 공통 — 모델(ControlElement)의 관찰 가능한 투영. +/// 모델이 진실이며 VM 은 버려도 되는 투영(Undo 복원 시 재생성)이다. +/// 좌표계: WinForms px = WPF DIP 1:1. +/// +public abstract class ControlViewModel : ViewModelBase +{ + #region Member Fields + private bool isSelected; + private bool isHovered; + #endregion + + #region Properties + /// 원본 모델(저장 원본 — 참조 보관) + public ControlElement Model { get; } + + /// 부모 컨테이너 VM — 최상위(페이지 직속)면 null. X/Y 는 부모 기준 상대좌표 + public ControlViewModel? Parent { get; set; } + + /// 컨트롤 이름(Id) + public string Id => Model.Id; + + /// 중립 타입명 + public string Type => Model.Type; + + /// 부모 기준 X(px) + public double X + { + get => Model.Bounds.X; + set { Model.Bounds.X = value; OnPropertyChanged(nameof(X)); } + } + + /// 부모 기준 Y(px) + public double Y + { + get => Model.Bounds.Y; + set { Model.Bounds.Y = value; OnPropertyChanged(nameof(Y)); } + } + + /// 너비(px) + public double Width + { + get => Model.Bounds.W; + set { Model.Bounds.W = value; OnPropertyChanged(nameof(Width)); } + } + + /// 높이(px) + public double Height + { + get => Model.Bounds.H; + set { Model.Bounds.H = value; OnPropertyChanged(nameof(Height)); } + } + + /// 선택 상태(뷰 전용 — 저장 안 함) + public bool IsSelected + { + get => isSelected; + set => SetProperty(ref isSelected, value); + } + + /// 호버 상태(뷰 전용) + public bool IsHovered + { + get => isHovered; + set => SetProperty(ref isHovered, value); + } + + /// 정적 텍스트(Text Property) + public string Text => Model.Props.GetText("Text") ?? string.Empty; + + /// 유효 폰트 — 부모 체인 상속 반영(WinForms Font 상속 규약) + public LegacyFont EffectiveFont { get; private set; } = new(); + + /// WPF FontFamily + public FontFamily FontFamily => new(EffectiveFont.Family); + + /// WPF FontSize(DIP) — pt × 96/72 + public double FontSize => Math.Max(1, EffectiveFont.SizePt * 96.0 / 72.0); + + /// 굵게 + public FontWeight FontWeight => EffectiveFont.Bold ? FontWeights.Bold : FontWeights.Normal; + + /// 기울임 + public FontStyle FontStyle => EffectiveFont.Italic ? FontStyles.Italic : FontStyles.Normal; + + /// 글자색 — ForeColor 상속 반영 + public Brush Foreground { get; private set; } = Brushes.Black; + + /// 배경색 — BackColor(명시 시) + public Brush Background { get; private set; } = Brushes.Transparent; + + /// 수평 텍스트 정렬 — TextAlign 계열 Property 해석 + public HorizontalAlignment TextAlignment { get; private set; } = HorizontalAlignment.Left; + + /// 상속 기점 부모 폰트(재해석용 보관) + public LegacyFont ParentFont { get; private set; } = new(); + + /// 상속 기점 부모 글자색(재해석용 보관) + public Brush ParentForeground { get; private set; } = Brushes.Black; + + /// 잠금 플래그(레이어 패널 토글) + public bool IsLockedFlag + { + get => Model.Locked; + set + { + Model.Locked = value; + OnPropertyChanged(nameof(IsLockedFlag)); + } + } + + /// 숨김 플래그(레이어 패널 토글) — 캔버스 반투명 표시 + public bool IsHiddenFlag + { + get => Model.Hidden; + set + { + Model.Hidden = value; + OnPropertyChanged(nameof(IsHiddenFlag)); + OnPropertyChanged(nameof(DesignOpacity)); + } + } + + /// 디자인 표시 불투명도 — 숨김이면 0.25 + public double DesignOpacity => Model.Hidden ? 0.25 : 1.0; + #endregion + + #region Constructors + protected ControlViewModel(ControlElement model) + { + Model = model; + } + #endregion + + #region Methods + /// + /// 상속 컨텍스트(부모 폰트/글자색)를 적용하고 표시 속성을 재해석한다 — DocumentMapper 가 매핑 시 호출. + /// + 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); + } + + /// 레거시 색 문자열 → WPF Brush + 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; + } + + /// 속성 변경 후 시각 재해석 — 보관된 부모 컨텍스트로 다시 해석 + public void RefreshVisual() => ResolveVisualContext(ParentFont, ParentForeground); + + /// 전체 속성 변경 통지(개명 등 계산 속성 갱신) + public void NotifyAllChanged() => OnPropertyChanged(string.Empty); + + /// 텍스트 Property 조회 편의 + protected string? Prop(string name) => Model.Props.GetText(name); + + /// bool Property 조회 편의 — "True"/"False" 문자열 + protected bool PropBool(string name, bool defaultValue = false) + => bool.TryParse(Prop(name), out var value) ? value : defaultValue; + #endregion +} diff --git a/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs b/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs new file mode 100644 index 0000000..ac49621 --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs @@ -0,0 +1,372 @@ +using System.Collections.ObjectModel; +using SheetMe.Core.Models; +using SheetMe.Core.Serialization; + +namespace SheetMe.Designer.ViewModels.Controls; + +// 타입별 컨트롤 ViewModel — DataTemplate 자동 선택(캔버스 렌더)의 키. +// 각 클래스는 얇은 표시 투영이므로 한 파일에 모아 관리한다. + +/// 라벨 (레거시 Label/MFormatLabel/MSequence) +public sealed class LabelViewModel : ControlViewModel +{ + public LabelViewModel(ControlElement model) : base(model) { } +} + +/// 텍스트박스 (레거시 TextBox) +public sealed class TextBoxViewModel : ControlViewModel +{ + /// 여러 줄 여부 + public bool Multiline => PropBool("Multiline"); + + /// 테두리 표시 여부 — BorderStyle=None 이면 숨김 + public bool ShowBorder => Prop("BorderStyle") != "None"; + + public TextBoxViewModel(ControlElement model) : base(model) { } +} + +/// 마스크 입력 (레거시 MaskedTextBox) +public sealed class MaskedTextBoxViewModel : ControlViewModel +{ + /// 입력 마스크 + public string Mask => Prop("Mask") ?? string.Empty; + + public MaskedTextBoxViewModel(ControlElement model) : base(model) { } +} + +/// 체크박스 (레거시 CheckBox/CheckLabel) +public sealed class CheckBoxViewModel : ControlViewModel +{ + /// 디자인 표시용 체크 상태 + public bool IsChecked => Prop("Checked") == "True"; + + public CheckBoxViewModel(ControlElement model) : base(model) { } +} + +/// 라디오버튼 (레거시 RadioButton) +public sealed class RadioButtonViewModel : ControlViewModel +{ + /// 디자인 표시용 선택 상태 + public bool IsChecked => Prop("Checked") == "True"; + + public RadioButtonViewModel(ControlElement model) : base(model) { } +} + +/// 콤보박스 (레거시 ComboBox) +public sealed class ComboBoxViewModel : ControlViewModel +{ + /// 선택지 목록 — Items Property(ItemsValue) 해석 + public IReadOnlyList Items => ItemsOf(Model, "Items"); + + /// 디자인 표시 텍스트 — Text 또는 첫 항목 + public string DisplayText => Text.Length > 0 ? Text : (Items.Count > 0 ? Items[0] : string.Empty); + + public ComboBoxViewModel(ControlElement model) : base(model) { } + + /// ItemsValue Property 를 문자열 목록으로 + internal static IReadOnlyList 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(); + } +} + +/// 리스트박스 (레거시 ListBox) +public sealed class ListBoxViewModel : ControlViewModel +{ + /// 항목 목록 + public IReadOnlyList Items => ComboBoxViewModel.ItemsOf(Model, "Items"); + + public ListBoxViewModel(ControlElement model) : base(model) { } +} + +/// 체크리스트 (레거시 MCheckedListBox) +public sealed class CheckListViewModel : ControlViewModel +{ + /// 항목 목록 + public IReadOnlyList Items => ComboBoxViewModel.ItemsOf(Model, "Items"); + + public CheckListViewModel(ControlElement model) : base(model) { } +} + +/// 날짜선택 (레거시 DateTimePicker) +public sealed class DateTimePickerViewModel : ControlViewModel +{ + /// 디자인 표시 텍스트 — 포맷 힌트 + 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) { } +} + +/// 컨테이너 공통 — 자식 컬렉션 보유(Panel/GroupBox) +public abstract class ContainerViewModel : ControlViewModel +{ + /// 자식 컨트롤(그리기 순서 — 마지막이 최상위) + public ObservableCollection Children { get; } = new(); + + protected ContainerViewModel(ControlElement model) : base(model) { } + + /// 시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속) + 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); + } + } +} + +/// 패널 (레거시 Panel/Panel2/MLayerPanel/MExpandablePanel) +public sealed class PanelViewModel : ContainerViewModel +{ + /// 테두리 표시 여부 — BorderStyle 존재 시(None 제외) + public bool ShowBorder => Prop("BorderStyle") is not (null or "None"); + + public PanelViewModel(ControlElement model) : base(model) { } +} + +/// 그룹박스 (레거시 GroupBox) +public sealed class GroupBoxViewModel : ContainerViewModel +{ + public GroupBoxViewModel(ControlElement model) : base(model) { } +} + +/// 선 (레거시 MLine) +public sealed class LineViewModel : ControlViewModel +{ + /// 선 색 — LineColor Property + public System.Windows.Media.Brush LineBrush + => Prop("LineColor") is { } color ? BrushFromLegacy(color) : System.Windows.Media.Brushes.Black; + + public LineViewModel(ControlElement model) : base(model) { } +} + +/// 이미지 (레거시 MPictureBox) +public sealed class PictureBoxViewModel : ControlViewModel +{ + /// 디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트 + public string Hint => Prop("DataInterfaceTag") ?? "이미지"; + + public PictureBoxViewModel(ControlElement model) : base(model) { } +} + +/// 계산박스 (레거시 MCalcBox) +public sealed class CalcBoxViewModel : ControlViewModel +{ + /// 수식 원문 — Formula Property + public string Formula => Prop("Formula") ?? string.Empty; + + public CalcBoxViewModel(ControlElement model) : base(model) { } +} + +/// 버튼 (레거시 MButton : WinForms Button — 클릭 시 DataActionTag 검색폼 호출) +public sealed class ButtonViewModel : ControlViewModel +{ + /// 액션 태그 표시(None 이면 빈 값) + public string ActionHint + { + get + { + var tag = Prop("DataActionTag"); + return tag is null or "None" ? string.Empty : tag; + } + } + + public ButtonViewModel(ControlElement model) : base(model) { } +} + +/// +/// 데이터소스 (레거시 MDataTable — 34×34 DB 아이콘, 런타임 비가시 쿼리 소스). +/// 디자인 화면에서만 배지로 보이고 인쇄/런타임에는 그려지지 않는다. +/// +public sealed class DataTableViewModel : ControlViewModel +{ + /// 쿼리 요약(툴팁/배지) — 첫 60자 + 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) { } +} + +/// Spread 격자선 1개(로컬 좌표) +public sealed class SpreadLine +{ + public double X1 { get; init; } + public double Y1 { get; init; } + public double X2 { get; init; } + public double Y2 { get; init; } +} + +/// Spread 셀 표시 1개(로컬 좌표) +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; +} + +/// +/// 표 (레거시 Spread — FarPoint). 디자인은 E_SpdMst 별도 저장 — +/// DB 서식 열기 시 파싱된 격자(GridInfo)를 주입받아 렌더한다(읽기 전용, 편집은 레거시 디자이너 사용). +/// +public sealed class SpreadViewModel : ControlViewModel +{ + private SheetMe.Core.Serialization.SpreadGridInfo? gridInfo; + + /// 파싱된 격자 정보 — 주입 시 지오메트리 재계산 + public SheetMe.Core.Serialization.SpreadGridInfo? GridInfo + { + get => gridInfo; + set + { + gridInfo = value; + RecalcGeometry(); + } + } + + /// 격자 정보 보유 여부(없으면 자리표시 렌더) + public bool HasGrid => gridInfo is not null; + + /// 내부 격자선(로컬 좌표, 외곽 제외) + public System.Collections.ObjectModel.ObservableCollection GridLines { get; } = new(); + + /// 셀 텍스트(로컬 좌표, 스팬 반영) + public System.Collections.ObjectModel.ObservableCollection CellViews { get; } = new(); + + public SpreadViewModel(ControlElement model) : base(model) + { + PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(Width) or nameof(Height)) + { + RecalcGeometry(); + } + }; + } + + /// 격자/셀 지오메트리 재계산 — 컨트롤 크기 내 클리핑(500×500 전체가 아닌 보이는 만큼만) + 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 { 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 { 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; + } + } +} + +/// 미지원 컨트롤 자리표시 — 원본 타입명 표시, 이동/리사이즈만 허용 +public sealed class PlaceholderViewModel : ControlViewModel +{ + /// 원본 레거시 클래스명 + public string LegacyClassName + => Model.LegacyAqn is { } aqn ? LegacyTypeCatalog.ShortClassName(aqn) : "Unknown"; + + public PlaceholderViewModel(ControlElement model) : base(model) { } +} diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs new file mode 100644 index 0000000..5a61748 --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs @@ -0,0 +1,1259 @@ +using System.Collections.ObjectModel; +using System.Windows; +using M.Framework.WPF; +using SheetMe.Core.Catalog; +using SheetMe.Core.Models; +using SheetMe.Core.Serialization; +using SheetMe.Designer.Services; +using SheetMe.Designer.ViewModels.Controls; +using SheetMe.Designer.ViewModels.Inspector; + +namespace SheetMe.Designer.ViewModels; + +/// 폰트 사용 집계 1조합 — (타입, 글꼴, 크기, 스타일)별 컨트롤 목록 +public sealed class FontUsageGroup +{ + /// 중립 타입명 + public string Type { get; } + + /// 글꼴 패밀리 + public string Family { get; } + + /// 크기(pt) + public double SizePt { get; } + + /// 굵게 + public bool Bold { get; } + + /// 기울임 + public bool Italic { get; } + + /// 밑줄 + public bool Underline { get; } + + /// Font 미지정(부모 상속) 그룹 여부 + public bool IsInherited { get; } + + /// 해당 조합의 컨트롤들 + public List Controls { get; } = new(); + + /// 목록 표시 텍스트 + public string Display => + $"{Type} · {Family} {SizePt:0.##}pt" + + (Bold ? " 굵게" : "") + (Italic ? " 기울임" : "") + (Underline ? " 밑줄" : "") + + (IsInherited ? " (상속)" : "") + $" — {Controls.Count}개"; + + public FontUsageGroup(string type, string family, double sizePt, bool bold, bool italic, bool underline, bool isInherited) + { + Type = type; + Family = family; + SizePt = sizePt; + Bold = bold; + Italic = italic; + Underline = underline; + IsInherited = isInherited; + } +} + +/// +/// 서식 문서 1개의 편집 허브 — 페이지 VM 트리·선택·스냅·Undo·상호작용·오버레이를 소유. +/// 모델(FormDocument)이 진실이고 VM 은 투영: Undo 복원 = 문서 교체 + RebuildFromDocument. +/// +public sealed class DesignerViewModel : ViewModelBase +{ + #region Member Fields + private const double PageGap = 28; + private const double WorldPad = 24; + private static readonly List clipboard = new(); + private double zoom = 1.0; + private FormDocument document; + private PageViewModel? selectedPage; + private bool isTabOrderMode; + private readonly List tabOrderSequence = new(); + private readonly Dictionary? spreadDesigns; + #endregion + + #region Properties + /// 편집 중인 문서(진실 원본) + public FormDocument Document => document; + + /// 파일 출처 경로(.xml/.json) — DB/신규 문서는 null + public string? FilePath { get; set; } + + /// DB 출처 여부 + public bool IsFromDb { get; set; } + + /// 이력 버전 열람 탭 여부 — 열람한 과거 SdgKey (활성본이면 null) + public decimal? HistorySdgKey { get; set; } + + /// 탭 헤더 표시명 — [코드] 제목 (출처) + public string DisplayName + { + get + { + var title = document.Title.Length > 0 ? document.Title : document.FormId; + if (HistorySdgKey is not null) + { + return $"{document.FormId} {title} (이력 {HistorySdgKey})"; + } + if (IsFromDb) + { + return $"{document.FormId} {title}"; + } + if (FilePath is not null) + { + return System.IO.Path.GetFileName(FilePath); + } + return title.Length > 0 ? title : "새 서식"; + } + } + + /// 탭 헤더 갱신 통지(저장/개명 후) + public void NotifyDisplayNameChanged() => OnPropertyChanged(nameof(DisplayName)); + + /// 페이지 VM 목록 + public ObservableCollection Pages { get; } = new(); + + /// 선택 서비스 + public SelectionService Selection { get; } + + /// 스냅 엔진 + public SnapEngine Snap { get; } + + /// Undo/Redo 서비스 + public UndoService Undo { get; } + + /// 마우스 상호작용 상태머신 + public InteractionController Interaction { get; } + + /// 선택/마퀴/가이드 오버레이 상태 + public SelectionOverlayViewModel Overlay { get; } = new(); + + /// 속성 인스펙터 + public InspectorViewModel Inspector { get; } + + /// 페이지 패널에서 선택된 페이지 + public PageViewModel? SelectedPage + { + get => selectedPage; + set + { + if (SetProperty(ref selectedPage, value) && value is not null) + { + Selection.ActivePage = value; + } + } + } + + /// 레이어 목록 선택 항목 — 캔버스 선택과 동기 + public ControlViewModel? SelectedLayerItem + { + get => Selection.Primary; + set + { + if (value is not null && value != Selection.Primary) + { + Selection.SetSingle(value); + } + } + } + + /// 줌 배율(0.25~3.0) + public double Zoom + { + get => zoom; + set + { + var clamped = Math.Clamp(value, 0.25, 3.0); + if (SetProperty(ref zoom, clamped)) + { + OnPropertyChanged(nameof(ZoomPercentText)); + } + } + } + + /// 줌 표시 텍스트 + public string ZoomPercentText => $"{zoom * 100:0}%"; + + /// 월드 논리 너비 + public double WorldWidth => Pages.Count == 0 ? 720 : Pages.Max(p => p.WidthDip); + + /// 월드 논리 높이 + public double WorldHeight => Pages.Count == 0 + ? 856 + : Pages[^1].OffsetY + Pages[^1].HeightDip + WorldPad; + #endregion + + #region Constructors + public DesignerViewModel(FormDocument document) + : this(document, null) + { + } + + public DesignerViewModel(FormDocument document, Dictionary? spreadDesigns) + { + this.spreadDesigns = spreadDesigns; + this.document = document; + Selection = new SelectionService(); + Snap = new SnapEngine(); + Undo = new UndoService(() => Document, ReplaceDocument); + Interaction = new InteractionController(this); + Inspector = new InspectorViewModel(this); + Selection.Changed += RefreshOverlay; + Selection.SetChanged += () => OnPropertyChanged(nameof(SelectedLayerItem)); + + UndoCommand = new Command((sender, e) => Undo.Undo()); + RedoCommand = new Command((sender, e) => Undo.Redo()); + DeleteCommand = new Command((sender, e) => DeleteSelection()); + CopyCommand = new Command((sender, e) => CopySelection()); + PasteCommand = new Command((sender, e) => Paste()); + DuplicateCommand = new Command((sender, e) => { CopySelection(); Paste(); }); + SelectAllCommand = new Command((sender, e) => SelectAllOnActivePage()); + BringToFrontCommand = new Command((sender, e) => ReorderSelection(toFront: true)); + SendToBackCommand = new Command((sender, e) => ReorderSelection(toFront: false)); + AlignCommand = new Command((object param) => Align(param as string ?? string.Empty)); + AddPageCommand = new Command((sender, e) => AddPage()); + RemovePageCommand = new Command((sender, e) => RemoveSelectedPage()); + GroupCommand = new Command((sender, e) => GroupSelection()); + UngroupCommand = new Command((sender, e) => UngroupSelection()); + CutCommand = new Command((sender, e) => CutSelection()); + SizeToControlCommand = new Command((object param) => SizeToControl(param as string ?? "Both")); + SpacingCommand = new Command((object param) => AdjustSpacing(param as string ?? string.Empty)); + CenterInPageCommand = new Command((object param) => CenterInPage(param as string ?? "H")); + TabOrderCommand = new Command((sender, e) => ToggleTabOrderMode()); + + RebuildFromDocument(); + } + #endregion + + #region Commands + /// 실행 취소 + public ICustomCommand? UndoCommand { get; set; } + + /// 다시 실행 + public ICustomCommand? RedoCommand { get; set; } + + /// 선택 삭제 + public ICustomCommand? DeleteCommand { get; set; } + + /// 복사 + public ICustomCommand? CopyCommand { get; set; } + + /// 붙여넣기 + public ICustomCommand? PasteCommand { get; set; } + + /// 복제(Ctrl+D) + public ICustomCommand? DuplicateCommand { get; set; } + + /// 활성 페이지 전체 선택 + public ICustomCommand? SelectAllCommand { get; set; } + + /// 맨 앞으로 + public ICustomCommand? BringToFrontCommand { get; set; } + + /// 맨 뒤로 + public ICustomCommand? SendToBackCommand { get; set; } + + /// 정렬(파라미터: Left/Right/Top/Bottom/CenterH/CenterV) + public ICustomCommand? AlignCommand { get; set; } + + /// 페이지 추가 + public ICustomCommand? AddPageCommand { get; set; } + + /// 선택 페이지 삭제 + public ICustomCommand? RemovePageCommand { get; set; } + + /// 그룹(Ctrl+G) — 선택을 한 단위로 묶음(편집 전용, 저장 안 함) + public ICustomCommand? GroupCommand { get; set; } + + /// 그룹 해제(Ctrl+Shift+G) + public ICustomCommand? UngroupCommand { get; set; } + + /// 잘라내기(Ctrl+X) + public ICustomCommand? CutCommand { get; set; } + + /// 같은 크기로(파라미터: Both/Width/Height — 기준 컨트롤에 맞춤) + public ICustomCommand? SizeToControlCommand { get; set; } + + /// 간격 조절(파라미터: HorizEqual/HorizIncrease/HorizDecrease/HorizConcat/VertEqual/...) + public ICustomCommand? SpacingCommand { get; set; } + + /// 페이지 가운데 맞춤(파라미터: H/V) + public ICustomCommand? CenterInPageCommand { get; set; } + + /// 탭순서 편집 모드 토글(시작/완료) + public ICustomCommand? TabOrderCommand { get; set; } + #endregion + + #region Methods - 문서/줌 + /// 모델 → VM 트리 재구성 — 열기/Undo 복원 시 호출 + public void RebuildFromDocument() + { + Selection.Clear(); + Pages.Clear(); + for (var i = 0; i < document.Pages.Count; i++) + { + Pages.Add(DocumentMapper.CreatePage(document.Pages[i], i)); + } + RecalcPageOffsets(); + Selection.ActivePage = Pages.FirstOrDefault(); + SelectedPage = Pages.FirstOrDefault(); + + // Spread 격자 주입(E_SpdMst 파싱 결과 — 컨트롤명 매칭) + if (spreadDesigns is not null) + { + foreach (var spread in AllControls().OfType()) + { + if (spreadDesigns.TryGetValue(spread.Id, out var grid)) + { + spread.GridInfo = grid; + } + } + } + + OnPropertyChanged(nameof(WorldWidth)); + OnPropertyChanged(nameof(WorldHeight)); + RefreshOverlay(); + Inspector.Rebuild(); + } + + /// 페이지 추가 — 문서 끝에 빈 페이지(720×856) + public void AddPage() + { + Undo.Snapshot(); + document.Pages.Add(LegacyXmlSerializer.CreateEmptyPage(document.Pages.Count + 1)); + RebuildFromDocument(); + SelectedPage = Pages.LastOrDefault(); + } + + /// 선택 페이지 삭제(확인 후) — 최소 1페이지 유지 + public void RemoveSelectedPage() + { + if (SelectedPage is null || document.Pages.Count <= 1) + { + return; + } + if (MessageBox.Show($"페이지 {SelectedPage.Index + 1}을(를) 삭제할까요? (컨트롤 {SelectedPage.Controls.Count}개 포함)", + "페이지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes) + { + return; + } + RemoveSelectedPageForSmoke(); + } + + /// 선택 페이지 즉시 삭제(확인 없음 — 스모크/내부용) + public void RemoveSelectedPageForSmoke() + { + if (SelectedPage is null || document.Pages.Count <= 1) + { + return; + } + Undo.Snapshot(); + document.Pages.Remove(SelectedPage.Model); + RebuildFromDocument(); + } + + /// 선택 페이지 용지 크기 적용(px) + public void ApplyPaperSize(double width, double height) + { + if (SelectedPage is null || width < 100 || height < 100) + { + return; + } + Undo.Snapshot(); + SelectedPage.Model.Width = Math.Round(width); + SelectedPage.Model.Height = Math.Round(height); + SelectedPage.Model.Root.HasSize = true; + SelectedPage.NotifySizeChanged(); + RecalcPageOffsets(); + OnPropertyChanged(nameof(WorldWidth)); + OnPropertyChanged(nameof(WorldHeight)); + } + + /// 문서 교체 후 재구성 — Undo 복원 경로 + public void ReplaceDocument(FormDocument newDocument) + { + document = newDocument; + RebuildFromDocument(); + } + + /// 페이지 세로 오프셋 재계산 + public void RecalcPageOffsets() + { + double y = 0; + foreach (var page in Pages) + { + page.OffsetY = y; + y += page.HeightDip + PageGap; + } + } + + /// 줌 확대 + public void ZoomIn() => Zoom = Math.Round(Zoom * 1.1, 2); + + /// 줌 축소 + public void ZoomOut() => Zoom = Math.Round(Zoom / 1.1, 2); + + /// 줌 100% + public void ZoomReset() => Zoom = 1.0; + #endregion + + #region Methods - 좌표/히트테스트 + /// 최상위 조상(페이지 직속) — 자신이 최상위면 자신 + private static ControlViewModel RootAncestorOf(ControlViewModel viewModel) + { + var current = viewModel; + while (current.Parent is not null) + { + current = current.Parent; + } + return current; + } + + /// VM 이 속한 페이지(컨테이너 자식 포함) + public PageViewModel? PageOf(ControlViewModel viewModel) + { + var root = RootAncestorOf(viewModel); + return Pages.FirstOrDefault(p => p.Controls.Contains(root)); + } + + /// 부모 체인의 월드 오프셋(페이지 OffsetY 포함) — 월드 = 오프셋 + (X,Y) + public Point ParentWorldOffset(ControlViewModel viewModel) + { + double x = 0; + double y = 0; + var parent = viewModel.Parent; + while (parent is not null) + { + x += parent.X; + y += parent.Y; + parent = parent.Parent; + } + y += PageOf(viewModel)?.OffsetY ?? 0; + return new Point(x, y); + } + + /// 월드 Y 로 페이지 판정 — 페이지 사이 간격은 가까운 쪽 + public PageViewModel? PageAt(double worldY) + { + foreach (var page in Pages) + { + if (worldY <= page.OffsetY + page.HeightDip + PageGap / 2) + { + return page; + } + } + return Pages.LastOrDefault(); + } + + /// 컨트롤의 월드 bbox(컨테이너 자식은 부모 체인 오프셋 반영) + public Rect WorldBoundsOf(ControlViewModel viewModel) + { + var offset = ParentWorldOffset(viewModel); + return new Rect(offset.X + viewModel.X, offset.Y + viewModel.Y, + Math.Max(1, viewModel.Width), Math.Max(1, viewModel.Height)); + } + + /// 선택 전체의 월드 bbox — 선택 없음이면 null + public Rect? SelectionWorldBounds() + { + Rect? union = null; + foreach (var item in Selection.Items) + { + var rect = WorldBoundsOf(item); + union = union is null ? rect : Rect.Union(union.Value, rect); + } + return union; + } + + /// 월드 좌표 최상위 컨트롤 히트 — 컨테이너는 자식 우선(재귀), 잠금/숨김 제외 + public ControlViewModel? HitTestControl(Point world) + { + var page = PageAt(world.Y); + if (page is null) + { + return null; + } + var local = new Point(world.X, world.Y - page.OffsetY); + return HitInList(page.Controls, local); + } + + private static ControlViewModel? HitInList(IList controls, Point local) + { + for (var i = controls.Count - 1; i >= 0; i--) + { + var vm = controls[i]; + if (vm.Model.Hidden || vm.Model.Locked) + { + continue; + } + var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height)); + if (!rect.Contains(local)) + { + continue; + } + if (vm is ContainerViewModel container) + { + var childLocal = new Point(local.X - vm.X, local.Y - vm.Y); + var childHit = HitInList(container.Children, childLocal); + if (childHit is not null) + { + return childHit; + } + } + return vm; + } + return null; + } + + /// 월드 rect 와 교차하는 컨트롤(마퀴 선택) + public List ControlsIntersecting(Rect worldRect) + { + var result = new List(); + foreach (var page in Pages) + { + foreach (var vm in page.Controls) + { + if (vm.Model.Hidden || vm.Model.Locked) + { + continue; + } + if (worldRect.IntersectsWith(WorldBoundsOf(vm))) + { + result.Add(vm); + } + } + } + return result; + } + + /// 오버레이(선택 박스/핸들) 재계산 + public void RefreshOverlay() + { + Overlay.UpdateSelection(SelectionWorldBounds(), Selection.Items.Count > 0); + if (Selection.Primary is not null) + { + Selection.ActivePage = PageOf(Selection.Primary) ?? Selection.ActivePage; + } + } + #endregion + + #region Events + /// 인라인 텍스트 편집 요청 — 캔버스 뷰(EditorLayer)가 구독 + public event Action? InlineEditRequested; + #endregion + + #region Methods - 인라인 텍스트 편집 + /// 더블클릭 인라인 편집 가능한 타입인지 — 레지스트리에 Text 속성 정의가 있거나 데이터소스(쿼리 편집) + public static bool IsTextEditable(ControlViewModel viewModel) + => viewModel is DataTableViewModel + || ControlRegistry.Find(viewModel.Type)?.Properties.Any(p => p.Key == "Text") == true; + + /// 인라인 편집 요청 발신 — InteractionController 의 더블클릭 경로(뷰가 대상 타입별 편집기 분기) + public void RequestInlineEdit(ControlViewModel viewModel) + { + if (!IsTextEditable(viewModel)) + { + return; + } + Selection.SetSingle(viewModel); + InlineEditRequested?.Invoke(viewModel); + } + + /// 인라인 편집 커밋(Text) — 변경 시에만 Undo 1스텝 + 시각 재해석 + public void CommitInlineText(ControlViewModel viewModel, string newText) + => CommitPropertyText(viewModel, "Text", newText); + + /// 속성 텍스트 커밋 공통 — 변경 시에만 Undo 1스텝 + 시각 재해석 + 인스펙터 갱신 + public void CommitPropertyText(ControlViewModel viewModel, string key, string newValue) + { + if (newValue == (viewModel.Model.Props.GetText(key) ?? string.Empty)) + { + return; + } + Undo.Snapshot(); + viewModel.Model.Props.SetText(key, newValue); + viewModel.RefreshVisual(); + viewModel.NotifyAllChanged(); + Inspector.Rebuild(); + } + #endregion + + #region Methods - 탭순서 편집 + /// 탭순서 대상 타입(입력 컨트롤 — 레거시 제외 규칙: Label/MDataTable 등 비입력 제외) + private static readonly HashSet TabOrderTypes = new(StringComparer.Ordinal) + { + "TextBox", "MaskedTextBox", "CheckBox", "RadioButton", "ComboBox", + "ListBox", "CheckList", "DateTimePicker", "CalcBox", "Button", + }; + + /// 탭순서 편집 모드 여부 — 모드 중 클릭은 순번 지정으로 동작 + public bool IsTabOrderMode + { + get => isTabOrderMode; + private set + { + if (SetProperty(ref isTabOrderMode, value)) + { + OnPropertyChanged(nameof(TabOrderMenuHeader)); + } + } + } + + /// 탭순서 메뉴 표시 텍스트 + public string TabOrderMenuHeader => IsTabOrderMode ? "탭순서 편집 완료(적용)" : "탭순서 편집 시작"; + + /// 모드 토글 — 시작: 배지 표시, 완료: 클릭 순서대로 TabIndex 적용(레거시 규약 0,100,200…) + public void ToggleTabOrderMode() + { + if (IsTabOrderMode) + { + ApplyTabOrder(); + ExitTabOrderMode(); + } + else + { + tabOrderSequence.Clear(); + Selection.Clear(); + IsTabOrderMode = true; + RefreshTabBadges(); + } + } + + /// 모드 취소(Esc) — 적용 없이 종료 + public void CancelTabOrderMode() => ExitTabOrderMode(); + + /// 모드 중 컨트롤 클릭 — 순번 지정/해제 토글 + public void ToggleTabOrderClick(ControlViewModel viewModel) + { + if (!TabOrderTypes.Contains(viewModel.Type)) + { + return; + } + if (!tabOrderSequence.Remove(viewModel)) + { + tabOrderSequence.Add(viewModel); + } + RefreshTabBadges(); + } + + private void ExitTabOrderMode() + { + IsTabOrderMode = false; + tabOrderSequence.Clear(); + Overlay.TabBadges.Clear(); + } + + /// 탭순서 적용 — 전 컨트롤 999999+i 초기화 후 클릭 순서 0,100,200…(레거시 TK_TABINDEXSAVE 이식) + private void ApplyTabOrder() + { + if (tabOrderSequence.Count == 0) + { + return; + } + Undo.Snapshot(); + + var index = 0; + foreach (var vm in AllControls()) + { + index++; + vm.Model.Props.SetText("TabIndex", (999999 + index).ToString()); + } + for (var i = 0; i < tabOrderSequence.Count; i++) + { + tabOrderSequence[i].Model.Props.SetText("TabIndex", i == 0 ? "0" : (100 * i).ToString()); + } + Inspector.Rebuild(); + } + + /// 대상 컨트롤 배지 갱신 — 지정된 것은 순번(파랑), 미지정은 "–"(회색) + private void RefreshTabBadges() + { + Overlay.TabBadges.Clear(); + foreach (var vm in AllControls().Where(v => TabOrderTypes.Contains(v.Type) && !v.Model.Hidden)) + { + var bounds = WorldBoundsOf(vm); + var order = tabOrderSequence.IndexOf(vm); + Overlay.TabBadges.Add(new TabBadgeInfo + { + X = bounds.X - 6, + Y = bounds.Y - 6, + Text = order >= 0 ? (order + 1).ToString() : "–", + IsAssigned = order >= 0, + }); + } + } + + /// 문서 전체 컨트롤 순회(컨테이너 자식 포함, 모델 순서) + private IEnumerable AllControls() + { + IEnumerable Visit(ControlViewModel vm) + { + yield return vm; + if (vm is ContainerViewModel container) + { + foreach (var child in container.Children) + { + foreach (var descendant in Visit(child)) + { + yield return descendant; + } + } + } + } + foreach (var page in Pages) + { + foreach (var vm in page.Controls) + { + foreach (var descendant in Visit(vm)) + { + yield return descendant; + } + } + } + } + #endregion + + #region Methods - 폰트 일괄 변경 + /// 텍스트 렌더 대상 타입(폰트 집계/일괄 변경 대상 — 레거시 frmFontManager 대상군 확장) + private static readonly HashSet FontTargetTypes = new(StringComparer.Ordinal) + { + "Label", "TextBox", "MaskedTextBox", "CheckBox", "RadioButton", "ComboBox", + "ListBox", "CheckList", "DateTimePicker", "GroupBox", "CalcBox", "Button", + }; + + /// + /// 문서 전체(컨테이너 자식 포함) 폰트 사용 집계 — (타입, 글꼴, 크기, 스타일) 조합별 컨트롤 목록. + /// Font 미지정(상속) 컨트롤은 유효 폰트 기준으로 집계된다. + /// + public List CollectFontUsage() + { + var groups = new Dictionary(StringComparer.Ordinal); + + void Visit(ControlViewModel vm) + { + if (FontTargetTypes.Contains(vm.Type)) + { + var font = vm.EffectiveFont; + var explicitFont = vm.Model.Props.Contains("Font"); + var key = $"{vm.Type}|{font.Family}|{font.SizePt}|{font.Bold}|{font.Italic}|{font.Underline}|{explicitFont}"; + if (!groups.TryGetValue(key, out var group)) + { + group = new FontUsageGroup(vm.Type, font.Family, font.SizePt, + font.Bold, font.Italic, font.Underline, !explicitFont); + groups[key] = group; + } + group.Controls.Add(vm); + } + if (vm is ContainerViewModel container) + { + foreach (var child in container.Children) + { + Visit(child); + } + } + } + + foreach (var page in Pages) + { + foreach (var vm in page.Controls) + { + Visit(vm); + } + } + + return groups.Values + .OrderBy(g => g.Type, StringComparer.Ordinal) + .ThenBy(g => g.Family, StringComparer.Ordinal) + .ThenBy(g => g.SizePt) + .ToList(); + } + + /// 선택 조합의 컨트롤 전체에 새 폰트 일괄 적용 — Undo 1스텝 + public int ApplyFontBulk(IEnumerable targets, LegacyFont newFont) + { + var controls = targets.SelectMany(g => g.Controls).Distinct().ToList(); + if (controls.Count == 0) + { + return 0; + } + Undo.Snapshot(); + var fontText = LegacyFormat.FormatFont(newFont); + foreach (var vm in controls) + { + vm.Model.Props.SetText("Font", fontText); + vm.RefreshVisual(); + vm.NotifyAllChanged(); + } + Inspector.Rebuild(); + return controls.Count; + } + #endregion + + #region Methods - 편집 + /// 팔레트 즉시 배치 — 활성 페이지 중앙 부근, 겹침 방지 오프셋(더블클릭/플라이아웃 공용) + public void AddPaletteItemAtCenter(string type) + { + var page = SelectedPage ?? Pages.FirstOrDefault(); + if (page is null) + { + return; + } + var offset = (page.Controls.Count % 8) * 16; + AddControlAt(type, new Point(page.WidthDip / 2 + offset, page.OffsetY + page.HeightDip / 3 + offset)); + } + + /// 팔레트 드롭 — 월드 좌표에 신규 컨트롤 생성(모델 index 0 = 최상위) + public void AddControlAt(string type, Point world) + { + var descriptor = ControlRegistry.Find(type); + if (descriptor is null) + { + return; + } + var page = PageAt(world.Y); + if (page is null) + { + return; + } + + Undo.Snapshot(); + + var local = new Point(world.X, world.Y - page.OffsetY); + var element = new ControlElement + { + Type = descriptor.Type, + Id = IdGenerator.NextId(document, descriptor.Type), + Bounds = new LayoutRect { W = descriptor.DefaultW, H = descriptor.DefaultH }, + }; + if (type is "Label" or "CheckBox" or "RadioButton" or "GroupBox") + { + element.Props.SetText("Text", descriptor.DisplayName); + } + + // 컨테이너 내부 드롭(깊이 1 — 컨테이너 안에 컨테이너 금지) → 상대좌표로 자식 배치 + var container = descriptor.IsContainer ? null : ContainerAt(page, local); + if (container is not null) + { + element.Bounds.X = Math.Max(0, Math.Round(local.X - container.X - descriptor.DefaultW / 2)); + element.Bounds.Y = Math.Max(0, Math.Round(local.Y - container.Y - descriptor.DefaultH / 2)); + container.Model.Children.Insert(0, element); + var childViewModel = DocumentMapper.CreateControl(element, container.EffectiveFont, container.Foreground); + childViewModel.Parent = container; + container.Children.Add(childViewModel); + Selection.SetSingle(childViewModel); + return; + } + + element.Bounds.X = Math.Max(0, Math.Round(local.X - descriptor.DefaultW / 2)); + element.Bounds.Y = Math.Max(0, Math.Round(local.Y - descriptor.DefaultH / 2)); + page.Model.Controls.Insert(0, element); + var viewModel = CreateAndAttach(element, page, attachToEnd: true); + Selection.SetSingle(viewModel); + } + + /// 페이지 로컬 좌표의 최상위 컨테이너(Panel/GroupBox) — 없으면 null + private static ContainerViewModel? ContainerAt(PageViewModel page, Point local) + { + for (var i = page.Controls.Count - 1; i >= 0; i--) + { + if (page.Controls[i] is ContainerViewModel container + && !container.Model.Locked && !container.Model.Hidden + && new Rect(container.X, container.Y, Math.Max(1, container.Width), Math.Max(1, container.Height)).Contains(local)) + { + return container; + } + } + return null; + } + + /// 선택 삭제 + public void DeleteSelection() + { + if (Selection.Items.Count == 0) + { + return; + } + Undo.Snapshot(); + foreach (var vm in Selection.Items.ToList()) + { + if (vm.Parent is ContainerViewModel parent) + { + parent.Children.Remove(vm); + parent.Model.Children.Remove(vm.Model); + continue; + } + var page = PageOf(vm); + if (page is null) + { + continue; + } + page.Controls.Remove(vm); + page.Model.Controls.Remove(vm.Model); + } + Selection.Clear(); + } + + /// 선택 복사 — 내부 클립보드(딥클론, 모델 순서 유지) + public void CopySelection() + { + if (Selection.Items.Count == 0) + { + return; + } + clipboard.Clear(); + var page = Selection.ActivePage; + var ordered = Selection.Items + .OrderBy(vm => page?.Model.Controls.IndexOf(vm.Model) ?? 0) + .Select(vm => vm.Model.Clone()); + clipboard.AddRange(ordered); + } + + /// 붙여넣기 — 활성 페이지에 +12,+12 오프셋, 새 Id 부여 + public void Paste() + { + if (clipboard.Count == 0) + { + return; + } + var page = Selection.ActivePage ?? Pages.FirstOrDefault(); + if (page is null) + { + return; + } + + Undo.Snapshot(); + var pasted = new List(); + var usedIds = IdGenerator.CollectUsed(document); + // 모델 순서(0=최상위) 유지: 역순으로 index 0 삽입 + foreach (var source in Enumerable.Reverse(clipboard)) + { + var clone = source.Clone(); + AssignNewIds(clone, usedIds); + clone.Bounds.X += 12; + clone.Bounds.Y += 12; + page.Model.Controls.Insert(0, clone); + pasted.Add(CreateAndAttach(clone, page, attachToEnd: true)); + } + Selection.Set(pasted, pasted.LastOrDefault()); + } + + /// 선택 z-order 변경 — 모델 index 0 = 최상위 / VM 마지막 = 최상위 + public void ReorderSelection(bool toFront) + { + if (Selection.Items.Count == 0) + { + return; + } + Undo.Snapshot(); + foreach (var vm in Selection.Items.Where(v => v.Parent is null).ToList()) + { + var page = PageOf(vm); + if (page is null) + { + continue; + } + page.Model.Controls.Remove(vm.Model); + page.Controls.Remove(vm); + if (toFront) + { + page.Model.Controls.Insert(0, vm.Model); + page.Controls.Add(vm); + } + else + { + page.Model.Controls.Add(vm.Model); + page.Controls.Insert(0, vm); + } + } + RefreshOverlay(); + } + + /// 방향키 넛지 — 400ms 코얼레스 Undo + public void NudgeSelection(double dx, double dy) + { + if (Selection.Items.Count == 0) + { + return; + } + Undo.SnapshotForNudge(); + foreach (var vm in Selection.Items) + { + vm.X = Math.Round(vm.X + dx); + vm.Y = Math.Round(vm.Y + dy); + } + Selection.NotifyBoundsChanged(); + } + + /// 활성 페이지 전체 선택 + public void SelectAllOnActivePage() + { + var page = Selection.ActivePage ?? Pages.FirstOrDefault(); + if (page is null) + { + return; + } + Selection.Set(page.Controls.Where(c => !c.Model.Hidden), page.Controls.LastOrDefault()); + } + + /// 선택 정렬 — 선택 bbox 기준(Left/Right/Top/Bottom/CenterH/CenterV) + public void Align(string mode) + { + if (Selection.Items.Count < 2) + { + return; + } + var bbox = SelectionWorldBounds(); + if (bbox is null) + { + return; + } + Undo.Snapshot(); + foreach (var vm in Selection.Items) + { + var offset = ParentWorldOffset(vm); + switch (mode) + { + case "Left": vm.X = Math.Round(bbox.Value.Left - offset.X); break; + case "Right": vm.X = Math.Round(bbox.Value.Right - vm.Width - offset.X); break; + case "Top": vm.Y = Math.Round(bbox.Value.Top - offset.Y); break; + case "Bottom": vm.Y = Math.Round(bbox.Value.Bottom - vm.Height - offset.Y); break; + case "CenterH": vm.X = Math.Round(bbox.Value.Left + (bbox.Value.Width - vm.Width) / 2 - offset.X); break; + case "CenterV": vm.Y = Math.Round(bbox.Value.Top + (bbox.Value.Height - vm.Height) / 2 - offset.Y); break; + } + } + Selection.NotifyBoundsChanged(); + } + + /// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + public void GroupSelection() + { + var targets = Selection.Items.Where(vm => vm.Parent is null && PageOf(vm) is not null).ToList(); + if (targets.Count < 2) + { + return; + } + Undo.Snapshot(); + var groupId = Guid.NewGuid().ToString("N"); + foreach (var vm in targets) + { + vm.Model.GroupId = groupId; + } + } + + /// 선택 그룹 해제 + public void UngroupSelection() + { + if (Selection.Items.Count == 0) + { + return; + } + Undo.Snapshot(); + foreach (var vm in Selection.Items) + { + vm.Model.GroupId = null; + } + } + + /// 그룹 동료 확장 — GroupId 가 같은 페이지 내 컨트롤 전체(클릭 선택이 그룹 단위가 되도록) + public List GroupMatesOf(ControlViewModel viewModel) + { + if (viewModel.Model.GroupId is not { } groupId) + { + return new List { viewModel }; + } + var page = PageOf(viewModel); + if (page is null) + { + return new List { viewModel }; + } + return page.Controls.Where(c => c.Model.GroupId == groupId).ToList(); + } + + /// 잘라내기 — 복사 후 삭제 + public void CutSelection() + { + if (Selection.Items.Count == 0) + { + return; + } + CopySelection(); + DeleteSelection(); + } + + /// 같은 크기로 — 기준(Primary) 컨트롤의 너비/높이에 맞춤 (레거시 TK_SizeToControl 계열) + public void SizeToControl(string mode) + { + var primary = Selection.Primary; + if (primary is null || Selection.Items.Count < 2) + { + return; + } + Undo.Snapshot(); + foreach (var vm in Selection.Items.Where(v => v != primary)) + { + if (mode is "Both" or "Width") + { + vm.Width = primary.Width; + } + if (mode is "Both" or "Height") + { + vm.Height = primary.Height; + } + } + Selection.NotifyBoundsChanged(); + } + + /// + /// 간격 조절 — 가로/세로 균등·넓게·좁게·붙이기 (레거시 TK_Horiz/VertSpace 계열). + /// 축 정렬 순서로 첫 컨트롤 고정, 이후 컨트롤 이동. 넓게/좁게 단위는 그리드 크기. + /// + public void AdjustSpacing(string mode) + { + var isHorizontal = mode.StartsWith("Horiz", StringComparison.Ordinal); + var minCount = mode.EndsWith("Equal", StringComparison.Ordinal) ? 3 : 2; + if (Selection.Items.Count < minCount) + { + return; + } + + // 월드 좌표 기준 정렬(정렬 명령과 동일 정책) + var ordered = Selection.Items + .Select(vm => (Vm: vm, World: WorldBoundsOf(vm), Offset: ParentWorldOffset(vm))) + .OrderBy(x => isHorizontal ? x.World.X : x.World.Y) + .ToList(); + + Undo.Snapshot(); + + double PosOf(int i) => isHorizontal ? ordered[i].World.X : ordered[i].World.Y; + double SizeOf(int i) => isHorizontal ? ordered[i].World.Width : ordered[i].World.Height; + + // 목표 시작 좌표 계산(월드) + var targets = new double[ordered.Count]; + targets[0] = PosOf(0); + + if (mode.EndsWith("Equal", StringComparison.Ordinal)) + { + var span = ordered.Max(x => (isHorizontal ? x.World.Right : x.World.Bottom)) - PosOf(0); + var sizeSum = ordered.Sum(x => isHorizontal ? x.World.Width : x.World.Height); + var gap = Math.Max(0, (span - sizeSum) / (ordered.Count - 1)); + for (var i = 1; i < ordered.Count; i++) + { + targets[i] = targets[i - 1] + SizeOf(i - 1) + gap; + } + } + else if (mode.EndsWith("Concat", StringComparison.Ordinal)) + { + for (var i = 1; i < ordered.Count; i++) + { + targets[i] = targets[i - 1] + SizeOf(i - 1); + } + } + else + { + var delta = mode.EndsWith("Increase", StringComparison.Ordinal) ? Snap.GridSize : -Snap.GridSize; + for (var i = 1; i < ordered.Count; i++) + { + targets[i] = PosOf(i) + delta * i; + } + } + + for (var i = 1; i < ordered.Count; i++) + { + var (vm, _, offset) = ordered[i]; + if (isHorizontal) + { + vm.X = Math.Round(targets[i] - offset.X); + } + else + { + vm.Y = Math.Round(targets[i] - offset.Y); + } + } + Selection.NotifyBoundsChanged(); + } + + /// 페이지 가운데 맞춤 — 선택 묶음을 페이지 가로/세로 중앙으로(상대 배치 유지, 레거시 TK_Center 계열) + public void CenterInPage(string axis) + { + if (Selection.Items.Count == 0) + { + return; + } + var bbox = SelectionWorldBounds(); + var page = Selection.Primary is not null ? PageOf(Selection.Primary) : null; + if (bbox is null || page is null) + { + return; + } + + Undo.Snapshot(); + var dx = axis == "H" ? Math.Round((page.WidthDip - bbox.Value.Width) / 2 - bbox.Value.X) : 0; + var dy = axis == "V" ? Math.Round(page.OffsetY + (page.HeightDip - bbox.Value.Height) / 2 - bbox.Value.Y) : 0; + foreach (var vm in Selection.Items) + { + vm.X += dx; + vm.Y += dy; + } + Selection.NotifyBoundsChanged(); + } + + /// 드래그 종료 후 소속 페이지 재배정 — 컨트롤 중심의 월드 Y 기준 + public void ReassignPagesAfterMove(List moved) + { + // 컨테이너 자식은 컨테이너를 따라가므로 최상위만 재배정 + foreach (var vm in moved.Where(v => v.Parent is null)) + { + var currentPage = PageOf(vm); + if (currentPage is null) + { + continue; + } + var worldCenterY = currentPage.OffsetY + vm.Y + vm.Height / 2; + var targetPage = PageAt(worldCenterY); + if (targetPage is null || targetPage == currentPage) + { + continue; + } + + // 페이지 로컬 Y 재계산 후 모델/VM 이동(최상위로) + vm.Y = Math.Round(currentPage.OffsetY + vm.Y - targetPage.OffsetY); + currentPage.Controls.Remove(vm); + currentPage.Model.Controls.Remove(vm.Model); + targetPage.Controls.Add(vm); + targetPage.Model.Controls.Insert(0, vm.Model); + } + RefreshOverlay(); + } + + private ControlViewModel CreateAndAttach(ControlElement element, PageViewModel page, bool attachToEnd) + { + var rootFontText = page.Model.Root.Props.GetText("Font"); + var rootFont = rootFontText is not null + ? LegacyFormat.ParseFont(rootFontText) + : new LegacyFont(); + var viewModel = DocumentMapper.CreateControl(element, rootFont, System.Windows.Media.Brushes.Black); + if (attachToEnd) + { + page.Controls.Add(viewModel); + } + else + { + page.Controls.Insert(0, viewModel); + } + return viewModel; + } + + private static void AssignNewIds(ControlElement element, HashSet usedIds) + { + // 붙여넣기 클론의 Id 재발급(트리 전체) — Name Property 는 쓰기 시 동기화됨 + element.Id = IdGenerator.NextId(usedIds, element.Type == LegacyTypeCatalog.PlaceholderType + ? "Control" + : element.Type); + foreach (var child in element.Children) + { + AssignNewIds(child, usedIds); + } + } + #endregion +} diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs new file mode 100644 index 0000000..cc87c7d --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs @@ -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; + +/// +/// 속성 인스펙터 — ControlRegistry 스키마 기반 행 구성. +/// 선택 '집합' 변경 시에만 재구성(드래그 프레임 제외), 다중선택은 값 동일성 병합("여러 값"). +/// 커밋 규약: 값 변경 시 Undo 스냅샷 1회 → 전체 선택 대상 적용 → 시각 재해석. +/// +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; + + /// 공통/글꼴 섹션이 이미 다루는 키 — 고급 목록에서 제외 + private static readonly HashSet HandledKeys = new(StringComparer.Ordinal) + { + "Location", "Size", "LocationOnBase", "Name", "Font", + }; + #endregion + + #region Properties + /// 행 목록(섹션 헤더 포함) + public ObservableCollection Rows { get; } = new(); + + /// 선택 요약 텍스트 + 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 + /// 경계(X/Y/W/H) 행 값만 갱신 — 이동/리사이즈 프레임(재구성 없이 가볍게) + 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); + } + } + + /// 행 재구성 — 선택 집합 변경 시 + 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(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); + } + } + + /// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가 + private void BuildAdvancedRows(ControlViewModel target, HashSet 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()), + 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 get, Action 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 get, Action 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 get, Action 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)); + }, + }); + } + + /// 편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화 + 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 +} diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs new file mode 100644 index 0000000..6e579c9 --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs @@ -0,0 +1,302 @@ +using System.Windows.Media; +using SheetMe.Designer.ViewModels.Controls; + +namespace SheetMe.Designer.ViewModels.Inspector; + +/// +/// 인스펙터 행 ViewModel — 라벨 + 문자열 정규화 값. 커밋 시(값 변경 시에만) 소유자 콜백으로 +/// Undo 스냅샷 → 전체 선택 대상 적용 → 시각 재해석이 수행된다. +/// +public abstract class PropertyRowViewModel : ViewModelBase +{ + #region Member Fields + private string valueText = string.Empty; + private bool building; + #endregion + + #region Properties + /// 행 라벨(한글) + public string Label { get; } + + /// 선택 대상들의 값이 서로 다른지 — "여러 값" 표시 + public bool IsMixed { get; private set; } + + /// 정규화 문자열 값 — 파생 편집기가 형 변환 + public string ValueText + { + get => valueText; + set + { + if (!SetProperty(ref valueText, value) || building) + { + return; + } + IsMixed = false; + OnPropertyChanged(nameof(IsMixed)); + Commit?.Invoke(value); + OnValueApplied(); + } + } + + /// 커밋 콜백 — InspectorViewModel 이 배선(스냅샷+적용) + public Action? Commit { get; set; } + #endregion + + #region Constructors + protected PropertyRowViewModel(string label) + { + Label = label; + } + #endregion + + #region Methods + /// 초기값 세팅(커밋 미발생) + public void Initialize(string? value, bool isMixed = false) + { + building = true; + valueText = value ?? string.Empty; + IsMixed = isMixed; + OnPropertyChanged(nameof(ValueText)); + OnPropertyChanged(nameof(IsMixed)); + building = false; + } + + /// 값 적용 후 파생 갱신 지점 + protected virtual void OnValueApplied() { } + #endregion +} + +/// 구분 헤더 행 +public sealed class SectionRowViewModel : PropertyRowViewModel +{ + public SectionRowViewModel(string label) : base(label) { } +} + +/// 한 줄 문자열 행 +public sealed class TextRowViewModel : PropertyRowViewModel +{ + public TextRowViewModel(string label) : base(label) { } +} + +/// 여러 줄 문자열 행(Text/수식/항목 목록) +public sealed class MultilineTextRowViewModel : PropertyRowViewModel +{ + public MultilineTextRowViewModel(string label) : base(label) { } +} + +/// 숫자 행 — 문자열 바인딩, 커밋 시 숫자 검증은 소유자에서 +public sealed class NumberRowViewModel : PropertyRowViewModel +{ + public NumberRowViewModel(string label) : base(label) { } +} + +/// 토글(참/거짓) 행 +public sealed class ToggleRowViewModel : PropertyRowViewModel +{ + /// 체크 상태 — "True"/"False" 문자열과 동기 + public bool IsOn + { + get => ValueText == "True"; + set => ValueText = value ? "True" : "False"; + } + + public ToggleRowViewModel(string label) : base(label) { } + + protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn)); +} + +/// 선택지 행 +public sealed class ChoiceRowViewModel : PropertyRowViewModel +{ + /// 선택지 목록 + public string[] Choices { get; } + + public ChoiceRowViewModel(string label, string[] choices) : base(label) + { + Choices = choices; + } +} + +/// +/// 태그 피커 행 — 값 표시 + 찾아보기 버튼(검색 대화상자). +/// 목록에 없는 사이트 커스텀 태그는 텍스트 직접 입력도 허용. +/// +public sealed class TagPickerRowViewModel : PropertyRowViewModel +{ + /// 피커 선택지(레거시 카탈로그) + public IReadOnlyList Choices { get; } + + /// 피커 대화상자 제목 + public string PickerTitle { get; } + + /// 찾아보기 — 검색 대화상자 열기 + public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; } + + public TagPickerRowViewModel(string label, string pickerTitle, IReadOnlyList 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; + } + } +} + +/// SQL 쿼리 행 — 요약 표시 + 전용 편집기 창(치환 변수 삽입) +public sealed class QueryRowViewModel : PropertyRowViewModel +{ + /// 요약 텍스트(한 줄) + public string Summary + { + get + { + var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim(); + return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine; + } + } + + /// 전용 편집기 열기 + 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; + } + } +} + +/// 색 행 — 레거시 invariant 문자열("R, G, B"/명명색) + 미리보기 스와치 +public sealed class ColorRowViewModel : PropertyRowViewModel +{ + /// 미리보기 브러시 + 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; + } + } + + /// 색상 피커 열기 — 확정 시 레거시 invariant 형식("R, G, B")으로 반영 + 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); + } +} + +/// 읽기 전용 행 — 중첩/바이너리/참조 등 raw 편집 불가 값 표시 +public sealed class ReadOnlyRowViewModel : PropertyRowViewModel +{ + public ReadOnlyRowViewModel(string label, string display) : base(label) + { + Initialize(display); + } +} + +/// 전체 속성(고급) 섹션 토글 행 — 표시/숨김 버튼 +public sealed class ToggleAdvancedRowViewModel : PropertyRowViewModel +{ + /// 버튼 표시 텍스트 + public string ButtonText { get; } + + /// 토글 실행 + 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()); + } +} + +/// 속성 추가 행 — 키 입력 후 빈 속성 생성(고급) +public sealed class AddPropertyRowViewModel : PropertyRowViewModel +{ + private string keyText = string.Empty; + + /// 추가할 속성 키(레거시 Property 이름) + public string KeyText + { + get => keyText; + set => SetProperty(ref keyText, value); + } + + /// 추가 실행 + public M.Framework.WPF.ICustomCommand? AddCommand { get; set; } + + public AddPropertyRowViewModel(Action add) : base(string.Empty) + { + AddCommand = new M.Framework.WPF.Command((sender, e) => + { + var key = KeyText.Trim(); + if (key.Length > 0) + { + add(key); + } + }); + } +} + +/// 인스펙터 행 컨텍스트 — 대상 컨트롤 집합과 접근자 +public sealed class RowBinding +{ + /// 값 읽기 + public required Func Get { get; init; } + + /// 값 쓰기 + public required Action Set { get; init; } + + /// 커밋 후 시각 재해석 필요 여부 + public bool AffectsVisual { get; init; } = true; +} diff --git a/src/SheetMe.Designer/ViewModels/MainViewModel.cs b/src/SheetMe.Designer/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..25f4ee1 --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/MainViewModel.cs @@ -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; + +/// +/// 메인 셸 ViewModel — 멀티 문서 탭(여러 기록지 동시 편집), 상시 서식 목록 패널, +/// 파일/DB 열기·저장, 도구(상용구/폰트). 컨트롤 클립보드는 문서 간 공유(서식 간 복사/붙여넣기). +/// +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 + /// 창 제목 + public string Title + { + get => title; + set => SetProperty(ref title, value); + } + + /// 상태바 텍스트 + public string StatusText + { + get => statusText; + set => SetProperty(ref statusText, value); + } + + /// 열린 문서(탭) 목록 + public ObservableCollection OpenDesigners { get; } = new(); + + /// 활성 문서(선택 탭) + public DesignerViewModel? CurrentDesigner + { + get => currentDesigner; + set + { + if (SetProperty(ref currentDesigner, value)) + { + Title = value is null + ? "SheetMe 서식생성기" + : $"SheetMe 서식생성기 — {value.DisplayName}"; + } + } + } + + /// 팔레트 컨트롤 목록(레지스트리) + public IReadOnlyList PaletteItems => ControlRegistry.All; + + /// 서식 목록(상시 패널) — DB E_ShtMst + public ObservableCollection SheetList { get; } = new(); + + /// 서식 목록 검색어 + public string SheetSearchKeyword + { + get => sheetSearchKeyword; + set => SetProperty(ref sheetSearchKeyword, value); + } + + /// 서식 목록 로딩 중 + public bool IsSheetListLoading + { + get => isSheetListLoading; + set => SetProperty(ref isSheetListLoading, value); + } + + /// DB 사용 가능 여부(서식 목록 패널 안내용) + public bool CanUseDb => dataBusiness.CanUseDb; + + /// 좌측 패널 탭(0=서식 목록, 1=레이어, 2=도구 상자) — 서식 열면 레이어로 자동 전환([200] 관행) + public int SelectedLeftTabIndex + { + get => selectedLeftTabIndex; + set => SetProperty(ref selectedLeftTabIndex, value); + } + + /// 손(팬) 도구 활성 — 캔버스 드래그로 화면 이동(false=선택 도구, [200] 플로팅 바 관행) + 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 + /// 창 로드 + public ICustomCommand? LoadedCommand { get; set; } + + /// 새 서식 + public ICustomCommand? NewFileCommand { get; set; } + + /// 서식 XML 열기 + public ICustomCommand? OpenFileCommand { get; set; } + + /// 저장 + public ICustomCommand? SaveFileCommand { get; set; } + + /// 다른 이름으로 저장 + public ICustomCommand? SaveAsFileCommand { get; set; } + + /// DB에서 서식 열기(검색 대화상자) + public ICustomCommand? OpenFromDbCommand { get; set; } + + /// DB에 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — SaveMode 게이트 + public ICustomCommand? SaveToDbCommand { get; set; } + + /// JSON 내보내기 + public ICustomCommand? ExportJsonCommand { get; set; } + + /// JSON 가져오기 + public ICustomCommand? ImportJsonCommand { get; set; } + + /// 미리보기 + public ICustomCommand? PreviewCommand { get; set; } + + /// 인쇄 + public ICustomCommand? PrintCommand { get; set; } + + /// 상용구 관리 + public ICustomCommand? RecordWordCommand { get; set; } + + /// 폰트 일괄 변경 + public ICustomCommand? FontManagerCommand { get; set; } + + /// 서식 수정이력(버전 열람/복원) + public ICustomCommand? SheetHistoryCommand { get; set; } + + /// 줌 확대 + public ICustomCommand? ZoomInCommand { get; set; } + + /// 줌 축소 + public ICustomCommand? ZoomOutCommand { get; set; } + + /// 줌 100% + public ICustomCommand? ZoomResetCommand { get; set; } + + /// 종료 + public ICustomCommand? ExitCommand { get; set; } + + /// 서식 목록 검색 + public ICustomCommand? SearchSheetsCommand { get; set; } + + /// 서식 목록에서 열기(더블클릭) + public ICustomCommand? OpenSheetCommand { get; set; } + + /// 문서 탭 닫기 + public ICustomCommand? CloseDocumentCommand { get; set; } + #endregion + + #region Methods - 문서 탭 + /// 문서를 탭으로 추가하고 활성화 — 좌측 패널은 레이어 탭으로 전환 + 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; + } + } + + /// 이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null + 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); + } + } + + /// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화 + 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 - 서식 목록 패널 + /// 서식 목록 로드(비동기) — DB 미접속이면 건너뜀 + 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; + } + } + + /// 서식 목록에서 열기 — 뷰 더블클릭 핸들러가 위임 호출 + 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 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 controls) + => controls.Sum(c => 1 + CountControls(c.Children)); + #endregion +} diff --git a/src/SheetMe.Designer/ViewModels/PageViewModel.cs b/src/SheetMe.Designer/ViewModels/PageViewModel.cs new file mode 100644 index 0000000..07f88f2 --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/PageViewModel.cs @@ -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; + +/// 페이지 ViewModel — 용지 1장의 크기·배경·컨트롤(그리기 순서) 투영. +public sealed class PageViewModel : ViewModelBase +{ + #region Member Fields + private double offsetY; + #endregion + + #region Properties + /// 페이지 모델 + public FormPage Model { get; } + + /// 페이지 번호(0-base) + public int Index { get; } + + /// 페이지 컨트롤(그리기 순서 — 마지막이 최상위) + public ObservableCollection Controls { get; } = new(); + + /// 용지 너비(DIP) + public double WidthDip => Model.Width; + + /// 용지 높이(DIP) + public double HeightDip => Model.Height; + + /// 월드 좌표 세로 오프셋 — DesignerViewModel 이 페이지 스택 계산 + public double OffsetY + { + get => offsetY; + set => SetProperty(ref offsetY, value); + } + + /// 용지 배경 브러시 — 루트 BackColor(기본 White) + 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 + /// 용지 크기 변경 통지 + public void NotifySizeChanged() + { + OnPropertyChanged(nameof(WidthDip)); + OnPropertyChanged(nameof(HeightDip)); + OnPropertyChanged(nameof(SizeText)); + } + + /// 페이지 패널 표시 텍스트 + public string SizeText => $"{WidthDip:0}×{HeightDip:0}"; + #endregion +} diff --git a/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs b/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs new file mode 100644 index 0000000..77a6f31 --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs @@ -0,0 +1,187 @@ +using System.Collections.ObjectModel; +using System.Windows; + +namespace SheetMe.Designer.ViewModels; + +/// 오버레이 가이드선 1개 — 월드 좌표 수직/수평선 +public sealed class GuideLineInfo +{ + /// 수직선 여부(false=수평선) + public bool IsVertical { get; init; } + + /// 선 위치(월드 X 또는 Y) + public double Position { get; init; } +} + +/// 탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단) +public sealed class TabBadgeInfo +{ + /// 배지 X(월드) + public double X { get; init; } + + /// 배지 Y(월드) + public double Y { get; init; } + + /// 표시 텍스트(순번 또는 "–") + public string Text { get; init; } = string.Empty; + + /// 순번 지정 여부(지정=파랑, 미지정=회색) + public bool IsAssigned { get; init; } +} + +/// 선택 핸들 1개 — 월드 좌표(중심점) +public sealed class HandleInfo +{ + /// 핸들 인덱스(0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W) + public int Index { get; init; } + + /// 핸들 좌상단 X(월드) + public double X { get; init; } + + /// 핸들 좌상단 Y(월드) + public double Y { get; init; } +} + +/// +/// 선택/마퀴/가이드 오버레이 표시 상태 — 전부 월드 좌표. +/// 오버레이 뷰는 이 상태를 그리기만 한다(입력 처리 없음). +/// +public sealed class SelectionOverlayViewModel : ViewModelBase +{ + #region Member Fields + /// 핸들 한 변 크기(논리 px) + 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 + /// 선택 박스 표시 여부 + public bool HasSelection { get => hasSelection; set => SetProperty(ref hasSelection, value); } + + /// 선택 박스 X(월드) + public double SelX { get => selX; set => SetProperty(ref selX, value); } + + /// 선택 박스 Y(월드) + public double SelY { get => selY; set => SetProperty(ref selY, value); } + + /// 선택 박스 너비 + public double SelW { get => selW; set => SetProperty(ref selW, value); } + + /// 선택 박스 높이 + public double SelH { get => selH; set => SetProperty(ref selH, value); } + + /// 리사이즈 핸들 표시 여부(잠금 선택 시 숨김) + public bool ShowHandles { get => showHandles; set => SetProperty(ref showHandles, value); } + + /// 핸들 8개(월드 좌표) + public ObservableCollection Handles { get; } = new(); + + /// 마퀴 표시 여부 + public bool HasMarquee { get => hasMarquee; set => SetProperty(ref hasMarquee, value); } + + /// 마퀴 X(월드) + public double MarX { get => marX; set => SetProperty(ref marX, value); } + + /// 마퀴 Y(월드) + public double MarY { get => marY; set => SetProperty(ref marY, value); } + + /// 마퀴 너비 + public double MarW { get => marW; set => SetProperty(ref marW, value); } + + /// 마퀴 높이 + public double MarH { get => marH; set => SetProperty(ref marH, value); } + + /// 정렬 가이드선 목록 + public ObservableCollection Guides { get; } = new(); + + /// 탭순서 배지 목록(탭순서 편집 모드 전용) + public ObservableCollection TabBadges { get; } = new(); + #endregion + + #region Methods + /// 선택 박스/핸들 갱신 — bbox 는 월드 좌표 + 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 }); + } + } + + /// 핸들 중심 좌표 8개(0=NW 시계방향) + 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), + }; + + /// 마퀴 갱신 — null 이면 숨김 + 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; + } + + /// 가이드선 교체 + 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 +} diff --git a/src/SheetMe.Designer/ViewModels/ViewModelBase.cs b/src/SheetMe.Designer/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..50090e4 --- /dev/null +++ b/src/SheetMe.Designer/ViewModels/ViewModelBase.cs @@ -0,0 +1,19 @@ +using M.Framework.WPF; + +namespace SheetMe.Designer.ViewModels; + +/// 프로젝트 공통 ViewModel 베이스 — BindableBase + 비동기 정리 지원. +public abstract class ViewModelBase : BindableBase, IAsyncDisposable +{ + #region Dispose + /// 비동기 정리 + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore(); + GC.SuppressFinalize(this); + } + + /// 파생 클래스 정리 지점 + protected virtual ValueTask DisposeAsyncCore() => ValueTask.CompletedTask; + #endregion +} diff --git a/src/SheetMe.Designer/Views/ColorPickerWindow.cs b/src/SheetMe.Designer/Views/ColorPickerWindow.cs new file mode 100644 index 0000000..76e97ac --- /dev/null +++ b/src/SheetMe.Designer/Views/ColorPickerWindow.cs @@ -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; + +/// +/// Figma 식 색상 피커([200]SheetMe ColorPickerWindow 이식) — 채도/명도(SV) 사각형 + 색상(Hue) 슬라이더 +/// + HEX·RGB 입력 + 프리셋/최근색. 정적 호출: 확정 시 "#RRGGBB" 반환, 취소 시 null. +/// 셸 색은 앱 테마 토큰(B.*) — SV/Hue/스와치는 색상 원본 그대로. +/// +public sealed class ColorPickerWindow : Window +{ + #region Member Fields + private static readonly List 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 + /// 확정된 "#RRGGBB"(취소 시 null) + public string? Result { get; private set; } + #endregion + + #region Methods + /// 색상 선택 대화상자 — 확정 시 "#RRGGBB", 취소 시 null. initialHex 로 초기색 지정. + 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 +} diff --git a/src/SheetMe.Designer/Views/DesignerCanvasView.xaml b/src/SheetMe.Designer/Views/DesignerCanvasView.xaml new file mode 100644 index 0000000..a5205ea --- /dev/null +++ b/src/SheetMe.Designer/Views/DesignerCanvasView.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs b/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs new file mode 100644 index 0000000..52d3c4b --- /dev/null +++ b/src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs @@ -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; + +/// +/// 디자인 캔버스 뷰 — 코드비하인드 허용 범위: ①줌 휠 처리(커서 중심 스크롤 보정) ②캔버스 포커스 ③인라인 텍스트 에디터. +/// 문서 변경 로직은 두지 않는다(커밋은 DesignerViewModel.CommitInlineText 경유). +/// +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; + } + } + + /// 대상 컨트롤 위에 편집기 표시 — 폰트/정렬 일치, 줌은 World LayoutTransform 이 처리 + 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 - 손(팬) 도구 + /// 손 도구 활성 여부 — 플로팅 바 토글([200] 관행) 또는 Space 누르는 동안 + private bool IsHandToolActive => + spacePanHeld + || (Application.Current.MainWindow?.DataContext as MainViewModel)?.IsHandTool == true; + + /// 팬 시작 — Preview 단계 선점으로 하위 선택/이동 로직(World 버블링) 차단 + 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; + + /// Space 누르는 동안 임시 손 도구 — 텍스트 입력 중에는 개입하지 않음 + 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 - 줌 + /// Ctrl+휠 줌 — 커서 논리 위치를 유지하도록 스크롤 오프셋 보정 + 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 +} diff --git a/src/SheetMe.Designer/Views/FontManagerDialogView.xaml b/src/SheetMe.Designer/Views/FontManagerDialogView.xaml new file mode 100644 index 0000000..5bda3bc --- /dev/null +++ b/src/SheetMe.Designer/Views/FontManagerDialogView.xaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml.cs b/src/SheetMe.Designer/Views/InspectorView.xaml.cs new file mode 100644 index 0000000..9150818 --- /dev/null +++ b/src/SheetMe.Designer/Views/InspectorView.xaml.cs @@ -0,0 +1,12 @@ +using System.Windows.Controls; + +namespace SheetMe.Designer.Views; + +/// 속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더. +public partial class InspectorView : UserControl +{ + public InspectorView() + { + InitializeComponent(); + } +} diff --git a/src/SheetMe.Designer/Views/MainView.xaml b/src/SheetMe.Designer/Views/MainView.xaml new file mode 100644 index 0000000..2196dac --- /dev/null +++ b/src/SheetMe.Designer/Views/MainView.xaml @@ -0,0 +1,540 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SheetMe.Designer/Views/MainView.xaml.cs b/src/SheetMe.Designer/Views/MainView.xaml.cs new file mode 100644 index 0000000..df73666 --- /dev/null +++ b/src/SheetMe.Designer/Views/MainView.xaml.cs @@ -0,0 +1,110 @@ +using System.Windows; +using SheetMe.Data.Stores; +using SheetMe.Designer.Services; +using SheetMe.Designer.ViewModels; + +namespace SheetMe.Designer.Views; + +/// +/// 메인 셸 윈도우 — 커스텀 타이틀바(로고+메뉴) | 좌(서식 목록/도구상자/페이지/레이어) | 문서 탭 | 인스펙터. +/// [200]SheetMe 크롬(WindowChrome CaptionHeight 40) 이식 — 창 제어/테마 토글은 뷰 전용 관심사라 코드비하인드 처리. +/// +public partial class MainView : Window +{ + public MainView() + { + InitializeComponent(); + // StaysOpen=False 팝업은 바깥 클릭(버튼 포함)으로 먼저 닫힌다 — 토글 버튼 재클릭이 곧바로 다시 열지 않게 닫힌 시각 기록 + AddControlPopup.Closed += (_, _) => addControlPopupClosedAt = Environment.TickCount; + ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount; + } + + /// 기동 — 테마 메뉴 체크 동기화 + 뷰모델 Loaded 커맨드 실행(구 CustomWindow.LoadedCommand 대체) + 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); + } + } + + /// 서식 목록 더블클릭 → 탭으로 열기(뷰모델 위임 — ListBox 더블클릭은 InputBinding 미지원) + 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; + } + + /// 플로팅 바 — 컨트롤 추가 플라이아웃 토글(닫힘 직후 재클릭 가드) + private void OnAddControlFlyoutClick(object sender, RoutedEventArgs e) + { + if (Environment.TickCount - addControlPopupClosedAt > 150) + { + AddControlPopup.IsOpen = true; + } + } + + /// 플로팅 바 — 줌 플라이아웃 토글 + private void OnZoomFlyoutClick(object sender, RoutedEventArgs e) + { + if (Environment.TickCount - zoomPopupClosedAt > 150) + { + ZoomPopup.IsOpen = true; + } + } + + /// 플라이아웃 타일 클릭 — 활성 페이지 중앙에 컨트롤 배치(팔레트 더블클릭과 동일 경로) + 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); + } + } + + /// 줌 플라이아웃 행 클릭 — 커맨드 실행 후 팝업 닫기 + private void OnZoomRowClick(object sender, RoutedEventArgs e) => ZoomPopup.IsOpen = false; + + /// 플로팅 바 — 선택 도구(기본) + private void OnSelectToolClick(object sender, RoutedEventArgs e) + { + if (DataContext is MainViewModel viewModel) + { + viewModel.IsHandTool = false; + } + } + + /// 플로팅 바 — 손(팬) 도구 + 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(); + + /// 최대화/복원 글리프 전환(Segoe MDL2: E922=최대화, E923=복원) + private void OnWindowStateChanged(object sender, EventArgs e) => + MaxGlyph.Text = WindowState == WindowState.Maximized ? "" : ""; +} diff --git a/src/SheetMe.Designer/Views/PageView.xaml b/src/SheetMe.Designer/Views/PageView.xaml new file mode 100644 index 0000000..b9ed03c --- /dev/null +++ b/src/SheetMe.Designer/Views/PageView.xaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SheetMe.Designer/Views/PageView.xaml.cs b/src/SheetMe.Designer/Views/PageView.xaml.cs new file mode 100644 index 0000000..a9f616b --- /dev/null +++ b/src/SheetMe.Designer/Views/PageView.xaml.cs @@ -0,0 +1,12 @@ +using System.Windows.Controls; + +namespace SheetMe.Designer.Views; + +/// 용지 1장 렌더 뷰 — DataContext 는 PageViewModel. +public partial class PageView : UserControl +{ + public PageView() + { + InitializeComponent(); + } +} diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml b/src/SheetMe.Designer/Views/PreviewWindow.xaml new file mode 100644 index 0000000..616cd32 --- /dev/null +++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml @@ -0,0 +1,25 @@ + + + + +