From 16c07f48dc4ae146c73344f816c47a21e6177134 Mon Sep 17 00:00:00 2001 From: Msystech Date: Tue, 11 Aug 2026 17:20:22 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EC=BB=A4=EB=B0=8B:=20Shee?= =?UTF-8?q?tMe=20=EC=84=9C=EC=8B=9D=EC=83=9D=EC=84=B1=EA=B8=B0=20(P0~P5=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=83=81=ED=83=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레거시 서식생성기(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 --- .gitignore | 13 + Directory.Build.props | 16 + README.md | 72 + SheetMe.slnx | 10 + src/SheetMe.Core/Catalog/ControlRegistry.cs | 258 ++++ src/SheetMe.Core/Catalog/LegacyTagCatalog.cs | 556 ++++++++ src/SheetMe.Core/Models/ControlElement.cs | 88 ++ src/SheetMe.Core/Models/FormDocument.cs | 40 + src/SheetMe.Core/Models/FormMeta.cs | 32 + src/SheetMe.Core/Models/FormPage.cs | 40 + src/SheetMe.Core/Models/LayoutRect.cs | 26 + src/SheetMe.Core/Models/LegacyPropValue.cs | 84 ++ src/SheetMe.Core/Models/PropBag.cs | 102 ++ .../Serialization/FarPointSpreadParser.cs | 145 ++ .../Serialization/FormJsonSerializer.cs | 233 +++ .../Serialization/IDesignSerializer.cs | 16 + .../Serialization/LegacyFormat.cs | 181 +++ .../Serialization/LegacyTypeCatalog.cs | 102 ++ .../Serialization/LegacyXmlSerializer.cs | 384 +++++ .../Serialization/XmlSemanticDiff.cs | 111 ++ src/SheetMe.Core/SheetMe.Core.csproj | 14 + src/SheetMe.Data/Config/DataConfig.cs | 28 + .../DataContexts/EmrDataContext.cs | 31 + src/SheetMe.Data/SheetMe.Data.csproj | 21 + .../Stores/OracleLegacyFormStore.cs | 426 ++++++ src/SheetMe.Data/Stores/RecordWordStore.cs | 200 +++ src/SheetMe.Data/Stores/SctMstXmlWalker.cs | 150 ++ src/SheetMe.Data/Stores/XmlFileFormStore.cs | 35 + src/SheetMe.Designer/App.xaml | 16 + src/SheetMe.Designer/App.xaml.cs | 172 +++ src/SheetMe.Designer/Assets/sheetme-logo.png | Bin 0 -> 59403 bytes src/SheetMe.Designer/Assets/sheetme.ico | Bin 0 -> 92207 bytes .../Behaviors/CanvasDropBehavior.cs | 48 + .../Behaviors/CanvasKeyboardBehavior.cs | 69 + .../Behaviors/CanvasMouseBehavior.cs | 110 ++ .../Behaviors/PaletteDragBehavior.cs | 76 + .../Controls/ControlTemplates.xaml | 375 +++++ src/SheetMe.Designer/Controls/LucideIcons.cs | 183 +++ .../Controls/PaletteIconCatalog.cs | 102 ++ .../DataBusiness/FormDesignDataBusiness.cs | 117 ++ src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 393 +++++ src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 408 ++++++ src/SheetMe.Designer/Services/ConfigLoader.cs | 44 + .../Services/DialogService.cs | 32 + .../Services/DocumentMapper.cs | 77 + src/SheetMe.Designer/Services/IdGenerator.cs | 49 + .../Services/InteractionController.cs | 422 ++++++ src/SheetMe.Designer/Services/PrintService.cs | 87 ++ .../Services/SelectionService.cs | 82 ++ src/SheetMe.Designer/Services/SnapEngine.cs | 125 ++ src/SheetMe.Designer/Services/ThemeManager.cs | 80 ++ src/SheetMe.Designer/Services/UndoService.cs | 94 ++ src/SheetMe.Designer/SheetMe.Designer.csproj | 43 + .../Themes/DesignerTheme.xaml | 806 +++++++++++ src/SheetMe.Designer/Themes/Tokens.Dark.xaml | 46 + src/SheetMe.Designer/Themes/Tokens.Light.xaml | 46 + .../ViewModels/Controls/ControlViewModel.cs | 193 +++ .../Controls/TypedControlViewModels.cs | 372 +++++ .../ViewModels/DesignerViewModel.cs | 1259 +++++++++++++++++ .../Inspector/InspectorViewModel.cs | 376 +++++ .../ViewModels/Inspector/PropertyRows.cs | 302 ++++ .../ViewModels/MainViewModel.cs | 690 +++++++++ .../ViewModels/PageViewModel.cs | 76 + .../ViewModels/SelectionOverlayViewModel.cs | 187 +++ .../ViewModels/ViewModelBase.cs | 19 + .../Views/ColorPickerWindow.cs | 338 +++++ .../Views/DesignerCanvasView.xaml | 73 + .../Views/DesignerCanvasView.xaml.cs | 294 ++++ .../Views/FontManagerDialogView.xaml | 41 + .../Views/FontManagerDialogView.xaml.cs | 79 ++ src/SheetMe.Designer/Views/InspectorView.xaml | 141 ++ .../Views/InspectorView.xaml.cs | 12 + src/SheetMe.Designer/Views/MainView.xaml | 540 +++++++ src/SheetMe.Designer/Views/MainView.xaml.cs | 110 ++ src/SheetMe.Designer/Views/PageView.xaml | 46 + src/SheetMe.Designer/Views/PageView.xaml.cs | 12 + src/SheetMe.Designer/Views/PreviewWindow.xaml | 25 + .../Views/PreviewWindow.xaml.cs | 73 + .../Views/QueryEditorWindow.xaml | 42 + .../Views/QueryEditorWindow.xaml.cs | 82 ++ .../Views/RecordWordDialogView.xaml | 45 + .../Views/RecordWordDialogView.xaml.cs | 154 ++ .../Views/RegisterSheetDialogView.xaml | 32 + .../Views/RegisterSheetDialogView.xaml.cs | 39 + .../Views/SelectionOverlayView.xaml | 114 ++ .../Views/SelectionOverlayView.xaml.cs | 12 + .../Views/SheetHistoryDialogView.xaml | 47 + .../Views/SheetHistoryDialogView.xaml.cs | 77 + .../Views/SheetOpenDialogView.xaml | 35 + .../Views/SheetOpenDialogView.xaml.cs | 64 + .../Views/TagPickerDialogView.xaml | 23 + .../Views/TagPickerDialogView.xaml.cs | 71 + src/SheetMe.Designer/appsettings.json | 13 + .../Assets/sheetdesign_sample.xml | 11 + .../FarPointSpreadParserTests.cs | 114 ++ tests/SheetMe.Core.Tests/FormJsonTests.cs | 43 + tests/SheetMe.Core.Tests/GlobalUsings.cs | 1 + tests/SheetMe.Core.Tests/LegacyFormatTests.cs | 86 ++ .../LegacyTypeCatalogTests.cs | 69 + .../LegacyXmlRoundTripTests.cs | 165 +++ .../SctMstXmlWalkerTests.cs | 71 + .../SheetMe.Core.Tests.csproj | 26 + 102 files changed, 14210 insertions(+) create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 README.md create mode 100644 SheetMe.slnx create mode 100644 src/SheetMe.Core/Catalog/ControlRegistry.cs create mode 100644 src/SheetMe.Core/Catalog/LegacyTagCatalog.cs create mode 100644 src/SheetMe.Core/Models/ControlElement.cs create mode 100644 src/SheetMe.Core/Models/FormDocument.cs create mode 100644 src/SheetMe.Core/Models/FormMeta.cs create mode 100644 src/SheetMe.Core/Models/FormPage.cs create mode 100644 src/SheetMe.Core/Models/LayoutRect.cs create mode 100644 src/SheetMe.Core/Models/LegacyPropValue.cs create mode 100644 src/SheetMe.Core/Models/PropBag.cs create mode 100644 src/SheetMe.Core/Serialization/FarPointSpreadParser.cs create mode 100644 src/SheetMe.Core/Serialization/FormJsonSerializer.cs create mode 100644 src/SheetMe.Core/Serialization/IDesignSerializer.cs create mode 100644 src/SheetMe.Core/Serialization/LegacyFormat.cs create mode 100644 src/SheetMe.Core/Serialization/LegacyTypeCatalog.cs create mode 100644 src/SheetMe.Core/Serialization/LegacyXmlSerializer.cs create mode 100644 src/SheetMe.Core/Serialization/XmlSemanticDiff.cs create mode 100644 src/SheetMe.Core/SheetMe.Core.csproj create mode 100644 src/SheetMe.Data/Config/DataConfig.cs create mode 100644 src/SheetMe.Data/DataContexts/EmrDataContext.cs create mode 100644 src/SheetMe.Data/SheetMe.Data.csproj create mode 100644 src/SheetMe.Data/Stores/OracleLegacyFormStore.cs create mode 100644 src/SheetMe.Data/Stores/RecordWordStore.cs create mode 100644 src/SheetMe.Data/Stores/SctMstXmlWalker.cs create mode 100644 src/SheetMe.Data/Stores/XmlFileFormStore.cs create mode 100644 src/SheetMe.Designer/App.xaml create mode 100644 src/SheetMe.Designer/App.xaml.cs create mode 100644 src/SheetMe.Designer/Assets/sheetme-logo.png create mode 100644 src/SheetMe.Designer/Assets/sheetme.ico create mode 100644 src/SheetMe.Designer/Behaviors/CanvasDropBehavior.cs create mode 100644 src/SheetMe.Designer/Behaviors/CanvasKeyboardBehavior.cs create mode 100644 src/SheetMe.Designer/Behaviors/CanvasMouseBehavior.cs create mode 100644 src/SheetMe.Designer/Behaviors/PaletteDragBehavior.cs create mode 100644 src/SheetMe.Designer/Controls/ControlTemplates.xaml create mode 100644 src/SheetMe.Designer/Controls/LucideIcons.cs create mode 100644 src/SheetMe.Designer/Controls/PaletteIconCatalog.cs create mode 100644 src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs create mode 100644 src/SheetMe.Designer/Diagnostics/DbSmoke.cs create mode 100644 src/SheetMe.Designer/Diagnostics/EditSmoke.cs create mode 100644 src/SheetMe.Designer/Services/ConfigLoader.cs create mode 100644 src/SheetMe.Designer/Services/DialogService.cs create mode 100644 src/SheetMe.Designer/Services/DocumentMapper.cs create mode 100644 src/SheetMe.Designer/Services/IdGenerator.cs create mode 100644 src/SheetMe.Designer/Services/InteractionController.cs create mode 100644 src/SheetMe.Designer/Services/PrintService.cs create mode 100644 src/SheetMe.Designer/Services/SelectionService.cs create mode 100644 src/SheetMe.Designer/Services/SnapEngine.cs create mode 100644 src/SheetMe.Designer/Services/ThemeManager.cs create mode 100644 src/SheetMe.Designer/Services/UndoService.cs create mode 100644 src/SheetMe.Designer/SheetMe.Designer.csproj create mode 100644 src/SheetMe.Designer/Themes/DesignerTheme.xaml create mode 100644 src/SheetMe.Designer/Themes/Tokens.Dark.xaml create mode 100644 src/SheetMe.Designer/Themes/Tokens.Light.xaml create mode 100644 src/SheetMe.Designer/ViewModels/Controls/ControlViewModel.cs create mode 100644 src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs create mode 100644 src/SheetMe.Designer/ViewModels/DesignerViewModel.cs create mode 100644 src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs create mode 100644 src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs create mode 100644 src/SheetMe.Designer/ViewModels/MainViewModel.cs create mode 100644 src/SheetMe.Designer/ViewModels/PageViewModel.cs create mode 100644 src/SheetMe.Designer/ViewModels/SelectionOverlayViewModel.cs create mode 100644 src/SheetMe.Designer/ViewModels/ViewModelBase.cs create mode 100644 src/SheetMe.Designer/Views/ColorPickerWindow.cs create mode 100644 src/SheetMe.Designer/Views/DesignerCanvasView.xaml create mode 100644 src/SheetMe.Designer/Views/DesignerCanvasView.xaml.cs create mode 100644 src/SheetMe.Designer/Views/FontManagerDialogView.xaml create mode 100644 src/SheetMe.Designer/Views/FontManagerDialogView.xaml.cs create mode 100644 src/SheetMe.Designer/Views/InspectorView.xaml create mode 100644 src/SheetMe.Designer/Views/InspectorView.xaml.cs create mode 100644 src/SheetMe.Designer/Views/MainView.xaml create mode 100644 src/SheetMe.Designer/Views/MainView.xaml.cs create mode 100644 src/SheetMe.Designer/Views/PageView.xaml create mode 100644 src/SheetMe.Designer/Views/PageView.xaml.cs create mode 100644 src/SheetMe.Designer/Views/PreviewWindow.xaml create mode 100644 src/SheetMe.Designer/Views/PreviewWindow.xaml.cs create mode 100644 src/SheetMe.Designer/Views/QueryEditorWindow.xaml create mode 100644 src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs create mode 100644 src/SheetMe.Designer/Views/RecordWordDialogView.xaml create mode 100644 src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs create mode 100644 src/SheetMe.Designer/Views/RegisterSheetDialogView.xaml create mode 100644 src/SheetMe.Designer/Views/RegisterSheetDialogView.xaml.cs create mode 100644 src/SheetMe.Designer/Views/SelectionOverlayView.xaml create mode 100644 src/SheetMe.Designer/Views/SelectionOverlayView.xaml.cs create mode 100644 src/SheetMe.Designer/Views/SheetHistoryDialogView.xaml create mode 100644 src/SheetMe.Designer/Views/SheetHistoryDialogView.xaml.cs create mode 100644 src/SheetMe.Designer/Views/SheetOpenDialogView.xaml create mode 100644 src/SheetMe.Designer/Views/SheetOpenDialogView.xaml.cs create mode 100644 src/SheetMe.Designer/Views/TagPickerDialogView.xaml create mode 100644 src/SheetMe.Designer/Views/TagPickerDialogView.xaml.cs create mode 100644 src/SheetMe.Designer/appsettings.json create mode 100644 tests/SheetMe.Core.Tests/Assets/sheetdesign_sample.xml create mode 100644 tests/SheetMe.Core.Tests/FarPointSpreadParserTests.cs create mode 100644 tests/SheetMe.Core.Tests/FormJsonTests.cs create mode 100644 tests/SheetMe.Core.Tests/GlobalUsings.cs create mode 100644 tests/SheetMe.Core.Tests/LegacyFormatTests.cs create mode 100644 tests/SheetMe.Core.Tests/LegacyTypeCatalogTests.cs create mode 100644 tests/SheetMe.Core.Tests/LegacyXmlRoundTripTests.cs create mode 100644 tests/SheetMe.Core.Tests/SctMstXmlWalkerTests.cs create mode 100644 tests/SheetMe.Core.Tests/SheetMe.Core.Tests.csproj 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 0000000000000000000000000000000000000000..a282eda32d7e048703f56a5c7230982fcdee036d GIT binary patch literal 59403 zcmZU4cQjjn{Qk`#v4YyQqOI1fy;smuqo`3;yJ}Ray@`aP!)S}zqE$t;M$u9$XzkUi znk8neTCs)v^7($h|NV0AJ-P35a?ZWa>-}2Kb6!14ORC>$kG2) zg2&Sy;y=O(r|g8LYXxgW&V1gi_~P)6*;+JfiIx~xa^|UaL#fiwM|$U1Tk;Y+)#3|& ziJv?dYw}wrR2;txW+FHI2e^|Ge~ZX{=JG3uC}p9W7j2w^UmMnqy1C@=^|&v|zhm%e z4RP>!&1ip;Z}1@9SJQr`>Wjq3lcJ=yPjg43SQ;68TWD1g9sb!hr|aNKWkjYRQ(rT} zn1SYU93b}=gPlp6%Mz*zeG?&~YMRu#Z1q+8IF>!pA@!Nx+OSMe0PT`bZ^vi*`p^*D ztH)A_odN$+-kN`sJ8XZ_=#y`|q|SPHz?@8gC#xmC|2Yud%>T5dIqGkiXZSnP()yZ7 zq0MOCqiUT`A#QOh;%*KBnpt7}n$q*xQDNav^5iAIU(NOF|3cIU8_j)xlOYW5J%=e$ z!d$Py$9NF_!ky5{(o*c15loqE;J)Tnk!qQ>4@oM4f^$x{pTuSf;rrkPf3L)aehE>Y zxcRiBi~8iAdp8sBMltwQ-0b+CVorVf^KQ|vu^}y8-{10Q-T5reu(>IPpNPHA-H1Hb z;=>0DtP8N2h)x7jd^3>&iU(Jr{_n%Hm%gW-5@B>#a(^g6&GPxjqPl2U)NI)B= zzT8FaEcm8Z@RR<5e=1GfDrXeyL95eB8sUc)pK>>; z(rrd&CO!}>b{QZp?AqL^QiGN4FA`!;>kqe30*X<%l)-3;wpnS0fUlQkE9}p0cbqFe zeEQ(JB!u!H=A!c1gU&Us?J`a4txjj6k$`~T8}bi7XOq*?(T@Uhx*uf%bn`-p36nX* z$Kx{t@SuU4EpGY)b(fPh#2GVntNqn0m2dIoEF6LrY^9z+IqhK6Opv8&m*;Y8<2L0B zL-%&_53>2~L8y&I6%{3whM7LoDU!Ux`=6Y%y@e`b*ZuT0&DwB!&6pq zALn(L{93V7$7}h<21&NwmcEYFih%8(SoO%rIsiv8d%}O^dF5s#9l}oZ$IBt}$J3u> z{2xdNaOcL2a!$q_BzH52VF`ys?gc_@`&QQzBa>Q#HzpZ8LVvEknjNkf$C=XkA#OH% z+UNQIFwFEH@5vAU@d4TS{xH0o=_TR*^X@7aOh_T<)e5N`Qmu=%5xRe=*7eUw@=X)9 zgY2aGg|SP$OvRUriw9rLdv^XYDc90(Nw`Td$zS~8<}f1H6oY+r2Bw9{l`CiRBN$c9 zl7mUFWFl?AdgX>~nF`&Z?ossby$pYRG?lCLh`4-vqrc;uPe9et3D`@`;faR(v+e#O zb3TMOj^nCosx>Y{Y(;Q={h)|EnU|U;+DEHmYp{goR`dR z+LW(dQLFnwT0ZI8(ww&f?x+X7c)(=4LH%U3R!3T2Oa?uyM{e+3B~8;Dn@c zXTc9T?z3shM4QyMs1=u8h4 zypSsKy=eT2=~vb~&xO78Io(Gd_$cfp(Xmi^tI^{%6)e7&&XtL zn`_;)!_oJVNh5tes;E%Ta(D;-qZ+V>k zp*}>e0+cERP92=g^lxpgVL-fh43th>eT*;kvthxeQYwmAqz37s=p|uL3QQ!r)1AmnLy7jFpf4Xw-7p)FlB4XVls0C>aYO`E zZm9X4T_z zCfJgRQF!)d!svjJwKqK&%(6$Cm{oAH8(ThDJ}~O`?XW87)eqyYTfVGU@l7-ftwE|@ zAC-#+1?P*kDRCM3Eo5Ih?m7xvxzLLsX8Oozhw{rvZt!Xka;U?(ou4n?Dwh6Y-=N)Z#D`oczHaTqy184f(xfWAcV4=} zb7$*1=k#PwY^~UE%ltLgeMgDbmoH>*&xWs-*Kb}a(-2OMYKhkito z9~bs%2F93;yMzVG(tUb&=K_QXfUL%D6?;xLXOTmWJkbB=?FHRT847&a*=dYHO-X@I! zcf%5AM|9@12RXws2|bZ&pSPx)`94yvG7Acd;`iC&4v!DUjTk#=EXw zTJb4F8BpHJ^8Rq8I^s1@;#ng+B~ct7U_rmt_FJb>wT#7z$W<`#N=M!nX6fh_H>1Ux z0zC_1OeP#gtJ4bld8Vj3>ziwxj9K8v<`L7Hvrc?jF?h8re2oFW+*nwkmtTsRDQ2Ni zEJM~bP$grhi`;kCcLOV&rEltL6A|q2_vQ}Rn_3wM@&KH>TuvPGM3>jZ^r=C5?r+?- z5}m=J27i9thiibe5+iqI17lRiyL;=aw%q!1l=p#sXA*4BL+-nbXs#@uEO>yF_9LUp zxf3d0KO=QMBMtZAUN{R0PioV;9y53?lD~+bKJV_~OA`YUDQ~$`OhmquEEu-PRE*M} zSStEN@Vo&Gx1w&*WtYP|KW*7Z9p=r!z?2AD>SoReK$!1nbmRpZiWF)s>B9=6eGiZK z{b2LSYTSd!N*RZ7wJTo6NET_vYdpSpd{t3A9#rITT1M!%yXRCzf}F%+Ctp3MXFrxx zO&m&&I2i%3=z$k6%ZgEb?)0)DH$2QM+AtMjL+%e$r;m_NQ==@>Nm3J8N$Ds4+do?) zBf~$ksC6F=rjjxUzxQPA5jEFPD0qwNbX3g&LD@243B(Ib_CnD$`W zpa@oO=y`BY;=-E&E;wW00ID6|%1#62LM$ZG%751uu5ikxg{pj3%V`*pbbie60@xZl zd8-4xoH}5T?sClG^xc`)KErM9-^kakfdtXaj^0qYD$BiHAN6a`LrWSexKE_a`b>k+ z|Hzuxgl(RfR=-Gdy(nota?HSQa?A=m>q=X92bLqin14=F`{wHV$pA`?BA2?5?+*rJ z!M`0WpDe9mvyH@)t;hKZ{_pNz+*F4@D1IAz~@53#9>Lv4%*`>jjBIgbxr1Bi1_42r2D*$5}_mN+^*LucgV{qr*1M6=-*ygu1-}RjGqh(1h-8AVeuGqAxo)!Reu*G< zc1A69tuppy0v+H4PgK1CAbEdCSIR7vpfFgJ3iz8flDlk&1`4s@uaj1Gjfa8bo?d0I zDczZS5MI71N&Ee=nh2Z;@eltLgdZiWV+XRBN`S@%1vEwvWGZIfD?lavcn!hp+=R8d^mxQUOmZfm%_%LwHHmyGWlR zlfxiF^Z~Uh8hQt-j==c+pLO!V;syPs=OU_JCU`ee(H%MPwqnvjIe5GR4akuD*@YMH z-b802XR(jrXL<(Lm>K5|RlN#`WC3B$Q-ZTWa_bHCS1#3ZYY|Kg%^BjLglN5-+ZPSr zu)MqBov8M&pnU?I7%D}Vs@Yh?xi{2@i)C)^Cb~8{=8Cj!RM%uzeD~g|=Pu5hN#I>4 ze_12H+Pz5pddt#qNoz8dJbI81u9R^#RmsmakKyuxpoH?X%rM2%qTh$vE~y;$?1CFr zih){AjsRt|D8KP9hUvlN2tqW4aL6SvqUpEskMxO6Uxu<3n>;4OdlB)HBo1ti$eGhJ zf|;PV>A#dQDurm7^@R$BhLy!XpOL z2@Sn9Xk2bfqWhV&;o*bv3O985o;Z={Cdg0X6>dRwD(PZMBYJE=jmk-4ptw-9KPcaV zp0Gj}7rK)NR+rHfG;K;valgVzD0*-d>Jkj{l)9?Z%Zyz$5>uo_!(!$gLj$Mh*ZG3z zxx?{(Nl59r0+bU}6nr3da1{>zSGiaUq35y;MGD2ZUuFz^g5NL9kYiyGQ4-_c8eu;F zIy!&$@ggqtzVefAb$@>cWN(iQR?7-4`VDB0O8($Jc94DVb)&83W@+Y$4XqThC^MSG zdbiW@v4;DPqm($q1e{~3l<$%h`*?H?X)Q|&zhW<3(1Wo!NUH*eJ_u)Zc&7>UKwi*i z-oX@KW}kbyO>@{;2Ub4|){LbhJPP&m)D*{t8yO9GLd;wZj#lxT+P-9lkY#54 zi&zA0^h1}fkbCV2<@j-K^iVY`E#LI7;2hNgb*u^B>J&Bx%K)~wstCU_$9!joNoODuz>B4Dy;?YE>sOFV0vMJe>|R~MNOP-;u!wNOJ69Zx zH+~%1cUs#gvvnPP%_yWqtYm(X;gQocXpfPSt&9xe+#LV_DScNPD?RWQ^T7`8kOyZtCLFZ8E>y+TTch?5Ow$omzH${JiqO ziw+?&9(~!+4Q;>bGA<}hswM?w;4YEtdN;4UiW2^PL_9cO4$j=RQpvvn7>ip1BIf{O zF-sv-GZ>{Qz>}V!uSX9R5X^b*etBW`hbT>G6sIt)(NL(uR!`$xJ$5FEa2S`e%A2bc zz8=s6Lo@7BCRk5;S}_$~jO1)&fiuDBg*i6#TtEzVWR)Q5UT z3gqKnrHr~S7EZr1mp=tU7yo|l#?HJX9MYt`#;G;H_-K#UgNEEIlA0sG9nv;;@eqP9S^lRm6*tX=JaqYIlq1f|5XX~alHB`s?0E| zlc6f(%hor$XM$}#zVY{4Ja=9`7F21ucvI7oXg#o5zApFq6zfOv_C%t1>Hs8-bnSNl z#hNb+EYkzR00h{=Ke79gFx~T`urOeIA=Dfk7V2_aU{y+L-j{F`=IR1&xzf7Lz*iBy zV7XD(yS;Ztzg*j?x2b_RUiTXcsYpVfz@f<)%=$waFjfTe38C&PeJv*@CLRRQE(H(p zmau4}oR%2+#9}t&%=n*(c@c)R5BN$Vl)e_EJwC`sp`4sb%VfWXJ++L7W%^ygyh-p1 zJVgpxD7|yuFJ8G*snP4hAT|BrB-bDP>U5*8f@|N*r{&e@(R5F)C~mP#`NFqY|d2~^5{NM@%2@*rk(sAm*GfvgnROi%miMSs5;X&G?qjDG65t{@>Z z9~+g`CYZWhb)YZdKv)^=T=;`HP<)C2YcvNAk&IwX%{4s0#PNpTkWa;J2s~}wFrD;6BBBTq_MQl|A-2$5d1gD;9S+a}2j9SYE4H7UD1VDuDZ75&> zXv)h9GADMmN41l-zB-51xqDpQ;Lm;zizgB`3;nl#*V#2Ht1wsecj^B7u~^d7=dPtu z_D-J=a#HZ--C;JysUGRu4voj~2$YP9Jyq68V4!7hBx8k9PQ&y%32bNG9WN6ax7zwuB7N#- zZV14En9zxkrdon~s?7zLan))6q<*B#E-Ld{VZ|57 zo=c`%IY+hY){|i?UM;vj3SDZ4uvjMf88rV>oGOEJ0_Q?*Zl{3H<5B zo$ar)VRa!M0%x+9GX6~qsW4*o^_K|URrR5d?p<}r%1=-=imLe=dSq1nTn&m2iJ9k; zKL4oooxlwEZwvI>rvH#jF&HBV_u3u5>51}(yoIx~u(KbF7fL%97z(U5Vo`Yi3Pupe z1;&#f0}(7qo5HUjUCW+P=^r*X7M`~0TVH!Y1%mpl_iQ?bqT^MM9!W}<~ zElpj>9f31LfZO=>^A_#Kgb?;8TBc@H&X_IXQ}CLviK!SyOlcbAg(h&98L0w*G3EdiC<5R>v_RT(E#^zT ztvHz5yj1LGzs(n3>eA}GDWdR389C;pnUEoZhGT2B1w*Xx ze;h^pBd+%(4q@C*Ca0~FP}TdQz}C8y1`w`5uwa7%X^_y+K#%}mR?3Oq!u+)J(cu~k z2#=#(nqxlg_TM@=8$TSzcunbgIw^}{z0|dCA%~peVkeBm{c*VtX5w27?0l5Rh1;C& zb_W$dj?}!Xe}?5Gl{+dgfBUPaBnxf}#)3e2M&|EuC?eb<6wgxmGbIryNHlz!tK{Tb zppDd{mOrx{voeU%{jFC|&&i6wedDxs;G#-m!v=a$3cB?33Q>O|{y80nd~U2i`Qy*% zm=Sl~@tw=|fJCF|K(sV7PB7Kw+ioAlC6wnsf_pJ^=^$52L#g1mdk@+ro{MdK^YR%u z@8nAdPEFkf0Lof2P63DfHy@}5KvqE0#PEeK3|n2xk?LJ481R(ZZwOuxwlH^a8nCT( z`gyyd+2#<}oOS3zF`kkWVPIzJZJRHw-dLh!c+U6?O5&TM=kBESPWsB^biyu zY2rrNa7=UAn(&gC6zvS`=7~BwT)zWqey4FFHT30gk+NTtof}&6#G>CEr>vEJy!%Mg zp|9e8^UIY{6G3=9{=f%@p`6Gk$EVMh8e9(z(i3vXb`Vj6ltKwwC3GHkngaYeDf(&P zI-D4wOWGQ3y+EPhfv(ogW4C#wCb{j&>8!&|>S-|}F{LKL#?09Nz3^i7R^K6CqpBoV z?Q6h_>-IYh>8j-s{HCQZIWCSgRF%0}buz{v84K{c`{x7no0MdFf)F z77|%!&7rt#*0!lZ^Qyv|@n`}9;b}4z(i!#USZ)%7^##Ef~bTHcC*Tedpu*`vqlatzjlg_=6C#U_FoB+Tu>GPW}^n#*H z43ZhW>y#Zv5YQd{0Q8y$H3gA=77pxe7RnjRsY%)l1Q|IjnH7IULNlIkY-3CVz`iMNi5xMK9;Yx9ppCLUd)f50@p>wPaxF z?u&00RSQ;Dcb;UpFKM1XJf%-=h-|FtSRKD7KD1;}(0@g70_|D&w-}3LQ5bq4E(!r- zxdZ}T1BQ~;8SaW+>2AyS;&h~evtL-2Zk3A%J9_VqyvL%D(oO^{CJkE>3yR=(og+jl z6wEeNXclTJ`BsQv92z77ZSqp$9Pc~*$d`D1nuA!R>PjjU?U4zisMA0lseW>#x{~08 zbNx7ay2D7A+~?8&1sono4Q>Bu)kp~8w+#Wl;TkNiqR{!gF=_N$R%6RGjles~=a&`L)~!pT0mOzbbp| zRY|fOe%Ed=^{S!#);k&WvSxb1%9tkW+pm-H2^{-q(kqan!;UQhTWR>dArx?FYh&LW zAY2V)Jh^DT6f_THO2~Rdg3+9dK4h+(@L_R(8$v6d>PcwlYZ4!yZ4L4i}0M6k$2~a?_ zUbCi!E54@rKJ@|5Fdwi@oIRp4C(V35{fCqw_=|*u2dqO+)CWLj2ZAFMIw0-z3bk$R zYO4spPtf{SG#?#g;Cd4D)skH9e*SDqivNJwu(R-@>~>8Is#fL5qlPy0^zRNs`Z328 z+o1+Sazmgc1@AB#4jZ~x9s4@RyJq5E;T~sa98d--f~$&L$M2UWCF91IukF__#=&j* z1OIx;FmgkCl-14t1T(+Q83{4C2&u z(|jBY+HHwnWa`B$SCZBB%rgVUKmP__Z!}+ICvok(6_7j`ykhy;0Yy{w zZBCxnpxv+MlkVuzr#?PYew#s!n;oZO^#>NXQ_)W1!FLh9AuDVyn88J9VfN)4o5NuO zpE}AYT7Os!Z#Si-a~JdXTmhJhw~!viiT*3cMw!;&r~FZ1O8@qLZbr4Zz`XcA*_L>T z{J~ZhJu1!_+pkZ0Wa}L` zV<>ILG9(SJTaAc+{Ouh34PlS#$T#4oKTCq<%kGiFoz`Jb+#VWAI|p$7LFXmy%`DCh z7t)LsC9j+#W=J;P*)M~r&HeJ4>I<|R$5|1O#a*_8j%MHk0)h!>IkdNN+Q%6ih5lRr zY7`yKK~RA3;4H#`gRpu?E8r{ofjw1oKs%{>CDh}_`Li)Kj+V8TF*CuYtv=t12`%4> z2&49|w#x<#4iA#Qnw6g3wDin*V)*HtS*a^nto8<~$Zfmi_a)Bs?2fnQ&y_7bITE6M z_>(AJyh$^`PdYangy-^`yF(-IpZ_q}t9v4eIz15Iw>rN@zPzQz#TU}co#;4X=kEA$ zk7vj$Hr9>zN4C#r9;l>}ipO>5RyW>HTERn0E;KPIbYR7a52LwKNRby8%z)+HtXPvk zE9nBWHzapOU5xfty%xM{xmgW#FGoEif+p4*4CWVuGj{#tU))_a9Ar{x#T9ANghyw@ zF0J**paZUI9xL*$weg(eqFC+!Q9Z zwA1E4if*~JIqG-(Z-8uJQtloU_CD`#P`<0^WV9ml46gUb zo9cSyP*oVrWO0sA%C}CDf#vMGI&~yG^^jFP7yXbeCL!VeeEox?2z}LoXj=4M%={y6 zx$T)+#W5L4El4(cp`cqIk$n!XsO({a94pawYLNE$^pT0D6cU4xjREUoeq#{KH8PBR z@!A=KUR8tqx1nD2^cx!DG`ccgwLf*WQMOXesvUCQieJP4s248|L|DfkUKyGzq)(j4 zP3eaPRIMOu_c`yu`1?PZ5J#9ae~$_V59h7eT?B`s#iaxc%`M=9B$ijPBaG5P#mP#DbuQR z!-n`48uV$*yt_d3-AqzKw%}c*>_?#U(aQx@@pyeGi1((_7-I~lm!fTNlHy(ObHma@ zD5NWXmEd9yz0axYRb|h{{X*7vC@UR=MPcwg1UWjkZ#w}D_FC0s{7{}%Y?6R2f4nwx zGne%4*izlWTh0Z!-6Mxd?2@1j_vbe(wuqmqXOUhSx4ZsET{>xLxgggPjNVu?t*viJ zaYtM1=lp9zOE%L2cKS_naI!0z!cT(fKL|r!Td2*rmPS0QIJLsw=05hl)jaCjiI@J- z^{o6y{lHC>ld!FLlk(F|+Esyx(9QSVEI1W!4l5t_`&6-4J11BM`GK7A?p~Rl`8wi zV8Lz0hCD3nnj9=oR5qfNJ`eH-UfCe)^6yDMvE)tg#r^;Js-*9%S_Hu)A)?9j@1$aBTWh24Zay) zy?>#4iJK}8P8MbbY}1d*b)*fG&cj3GW4S)`v_kP43!~;O=`A^N zfF23~LsY_%5^y9Go^_=5NeurUlD1w*i@UI{Vv)F6QbF+)HHpudkEwc%Q`~uSkaOf} zAhowgaubkm|I0~zx#FPGRmO?4aO-iuTM`d&t=D!<5W8yLfBWUq1Zn;5;b8hEW8MI!gE3Ty4nm+a@vZP zp8KwS@%a|de?iu}-48U@$zc?(wWE<82QBwsh0SADjz#kelpdsq^V{SR05fKv zii(cUHR*gjdHtDueJt!V_lZZ#nNiW9jOFVchi66STYh|YtXRB4BtFJ@?##}Aqwn`_ zB=dh~1l3r0zcnc19{a$YWF0bySscWlb@tKhSm zeDNogHyn?X4^GcoGQx;Fho^iz+RtC50Q+{(v*LeAXV{r`!eLg&P}x8eB>7J1o)Qo= zI@ZJY+g4Y)(*7Y{55*!~CylhK9MDS*Qp_&DV!?7~mIfri-@!HguW>|P`V$@-tH}Ff zf|{w%LHTN^>0v5$C;)wiN=hREmjTV}j6UwB=jm&2l($_UX*lqIheqCrg@o^zugTou{3+CJbk((ebGHB`pbx*-Dvtl-`WRIHLe0i?WA)$1}Y4$;~fgdiB9WdKhU3QUoJY!lzR= zSkh|_=kATjbxo_sQV&Eo48CJ5mY4^3Taj?sh-gOJ3s8b<+@<5LH?=08Q%%)1Rl6biI+3va+ zZaYNhpVQLE^;5*A94-5X)56rkvgOdhxMJs+ChpRt#PjmcoS3uG&B36Ge1AW8-}%jt zAc9H&#^t2{aL1=mh!AzV$sP3;ReMFS&eH$?pogA{llM<~gBQkz@i|7`zwbOK?1-R3 z4=9SgEAD-pwiXWWS#8`b*bt+M9r=QBs!gJO2#3C-<+}~JDbM&v)#KZ1lv82WH$_LE z!XK)Y)wcnT`u?+*+< zkPFvSytjcLti09VV&@i=eJU;iTjW4|*${8t%8KJwEM#$+k^KJnM_g!xHfTCKC*^4E z8B3m1Rr_CB$)HV@`ZrF-;{t>}0q8hvjYX$=a#|LM;pIELA~#x3CYNVVdGrazQ3($v}3O7k<8S_&cjVU}~UMxX|g=gQgrh z@ZBBUqKATnP=xgge*0W=jS!bW3sC5QX|+cGR3;Qe3qWO9lNye0B&aS7E&Gts?c8M2C+i2NP9)4>k#h$6fyD{(eo*U~7sVJ9t?d zE}Uqi`acRl$?m%pIg(ohQ^KS! zbeIoT$9@;P6ua}&IS@3_oSyLI;?B^cYnrw>vtb?@>dgP9d#=YekB2@CMi5~lAmYH* z@#f~h{@v!8tN#`?2J82{D%{b3_pWSq2WT#iMY@sIlPY-^TUiK?sHO=U{a496jJc&X z7W$|>X_Rw3)a7YxkCC^fy_+2T!_}{5QS~txMF=fBXQkMkm4b~E#nmPsIe3l2okc9h zxiZ_ysc_}Iep^hf0ORsROhM(JIdLq?Ax94XT9jQM70fZ_g;TFXHq=!F^j|0+l5=>>GHbQ_&b4pic4u$gyy9FaH?nun|$ z(_0i)j4kLf=N)l?;HPpMdTkyp+lw6)qskujf3%wpBx<56;0>nK5uoQq%DzvN7t8V8Pw2-RO!xNm zuNXN}1E!nPA@D|Vp(=UzmBVH)b*m^@KXZiaISTlu`zzN*%gNTW81Vy*8_N@oTwy`2+=h}dK>R^c zT?yj_Hk>BvSExV5+T9EyXvgY#L>^_~H=TXSb}q29DCc)kVNvHQ&)&ys!@Sg0dOOM( zBpaKDAgwt@P_UJ#ystK)WQaQ4A>v9X_(KoI-r;u*;-Obu{Zw-+x~QGSqt2WDx)4X!cyof_# z$PX+vf9keRAJGEeS0)kG0Qbv;+wJ~8y;wjOMwTQES+8+2QlG4^U1>2;bagwJTZ(DTF31yY)mE|>Ae>uP}e+Rk~Yn#XKPWe^&Sumh%YEraQ& zzy1>IiuVo&$!GTHSL+rhVfEU6%20<~GA^tx-Gl7COJyh^+Za|MQMuIvw#|yQ z=K4SeM8O;ambY%F>p;O+XJ}0JeQ2z!qNiwClO4*;hei~VlQ&L-#i@M#%f`M;N++f{ z?S1{6m~mfAg|hO5e!A9rkbL}3AnS;qaz6Ua$8}Q1>D`@&EKtMyQG3}B?1&>QzK#zQ zlt3)X!@OFXIzQz3IhP}20H;nR4v(o87JhwJfc_`?K31Hf0xJN9LQz-mGIs0ed}eli z-IDqc*pl$++c=g(%QdRyFz~4p_cwUnuGT)veLK1{6$?u0vTdIKADH8`XWlD8Yg2kO z7=$gLvJw)lgW8AYQ<8vlIt(oA}l*5g3vU;!Ff2O{b#KU@4%|YfzxaS zcp(e-+np2=?ENiguwT4{#c6WX)#LY9eBNodGEa#ZI1TVpkYK(^yYzOPAo2amk=atL zWBp(p_5-x8DjHM6rQ*{Q$$PZ0Fj1fWQ_lR>`}wuUtKPs335v{?Ewy!?zr*jHH}MMm z@oDfDIxRNk9DkqYJfW&oF+b;`K`YnNc&J}K9>dYva87`l2v}Jpg3Yp=s76Wuq4>cw zz?mS;c!x)#mIm6pj4|L1y1~K1@|u-VBG_NXNV|15V0l%ldV6kHwr|WKhRyjGf#g#6 z`!MX*Ad^aILhj5|SFGupOzuqo{#qu^5PiSrCq$0$o_;IDcX+!=8k^60?XZ4rfgXvL zY3^H{ZhC#O!jZaeeYq@DBxT&-e7WDcY;H9qA#CTDOYyR3Dm1J{jqu9cn zF)uM)mwIvxIfs5w|5|L%z%e>shQt7nZw~ICqlE88hn45q<=U#%P?}~BR~6bZSSQ1% zYC~UG0Eoa0qX%hHKCtgalAplgWXQe} z(p?M)_jcRgOEk4+x+Oz$I2P$6P8?|fcl06ut8$Fo+J7loik}bx;d?}QAI|Z6JuOnl z#76g-89F7$aV?E8g3X~){Zu`myDh}K@vJ;ai8J(e7#6@f<55)VRPtqv>KMO}m1ey( z;8qkLu6*ycN+?>7=J=yzN6Ki=onu_xBW10KWxQ{D|nFHA)D>QDB|6i!3@&rqZu zOpi*S72XVZe}2eZ!|d3obdb!Wz*c6Hmz%?RaQH4DBcbk@QrDhns;SFCtE~Kb8K|Hq zx|~ELtvX`1e+!AMs0BO0r0Du!9xPq%|D!Z;Rw2VvqM zc3s{(^qe);Y&Gf02f_>o=X~nuOO7XXx3I71ZBUk$8-n1_Z4FBiLjT59VA;X@rAhMO zfRA16&nL~Uf(SB#S}m1d$J?H08PeX?SG~i(=yda4Eb|5akI#ZDwR&3%{vkH?A#p$1rjYfLM(s(!W!^=@vb4oQeaq&jVt$M$SalTFlL!93vWk2U@lpjvedv55vWzJ(1?SIA~!|8r(Pb{{NC~c5>etgXTF2J#+3#~M( z4-9#KD9-}Sgs;}_n6hJTgQq_ZE*(q&F}znp#{=cA`D(d~(AbQ-yYvp6+vT>!d}*g2 za`VufVCvN+-a1-Vy^O2y?kq$5QaEh2ah*ma zpdE1a6uf?(Rp}EF*mz%cp>3i`G@Tt2t8mMS1}}|#9#AQj<$0jEk>fPq&BHlq0mV{n zmghQuG_L4?ym%cYo#`^~`njI!duxP|FoZ*4^M8(Lz0`)XSv*qw@Ir>2&(hIpe$j^0 zvtO$HjO41!W-=LKMbP=kEVRwp##TeJ{WZ}$u-T2C?u#8<9gaBQeI8LaV(m^j1+g67 z?K~~7UK_&jPtP3H2IPD|4ep14G57cQBbyr-M^xDh7M;46L%3h2B>OBD&foa3R~Ch27^-bk+%vbc$QL^iE76D#=f!cLw)GqwZrIeoFPJ8d75W ze>8o0I8<-={(H{MFvd37vKwVzLS!$8R48ji%2Em`l0EC0gi0i(%{G-H6%|UBjAY3! zWM}Ma_AJ?c=kxu3fB(9!xi05jp67n<=YH;&{olnGNAI!u2`0s7+N9Gsj_DM@kj>G7k|5+&Q+SPSw0eX=;vzQD4Y1|3F8MV6S%Db|2!?Etn zO-%vwbVH#XXJTiN2a`U%JTI>Xi?XfXm-2{Ae96o>*4+Anh3s=M9;%pb3)XEqM=num zs!CpRAN#T}b$9=au0uTQ{ZY-ZKKZ4nx4dB`mz8wN?CY9}*Md~a>NkV0-TRU=(6G65 zcg~5l9D~|!tEM^#cNf>zi}4Ev#T{}a(q5GTTJJa|mwvR2rE+Fdq)VG*i=UFBSd~kvl=E)BWt^?a^S`m{U2LGb zR|EccH3}h%>5z9{)y6LMM=?EXYCo&S-eM8VdHA_3U)mF|L98rFd*+pQ@LYrK&Rv; zf7oLGzxJyLHr=n zx5dwUpH7kbpS94YDGIku@db=8x-u)gJheq_RN^oVk(zkbO8qoY&rAe{2gvYH>$|!C z69+Z}H}@F-E}?H)Y1aJ<4Z^yv^|>jAxV7!&^etQtP4xapD|C7szF z_*eg&oS8jt-8#);Y3g1?} zHXPsjqm=<<@E;t;R{_J>=4RyRAjirpExXOg$g=o?g=ReKJ@b$A_hi-6EXb4)k7rSt zNe7QBKiNkK@_J22gtRtdGsv!qT|wuf<2DBNRir4@k@@B-v~0$POE>&3UyY7C^Kf$2 zH@q~vN#|{o;%V8gxf|G+nCC0xxX>SFsWRkJ$Z@)O*l|H(|K*`P@n6M&E5X3QE zcoAOaSJI}=ezk!cH1CL+e+$Vr8Mj$3D;Ox9+zh9X3us{8^M7&N!la_x&&IRPVG5E^ z(y-H)k0w#z!oRLWWg#ai9lK?01>$_?={oA7$yWwH%HnZnoRyDQm!CYTsB5a;V8N7g z+C)S%h}uw9>QbvRwq$klhYch2A*XH4x@>E%>-G;nR%6%8O41wklBFg&g2n6Z#3!Ix zj?PZlq%b(~UxeN38DlfEkBM!lg{|r_vgXFLFk*K>tDEEJFn{{+_Lmx?6?u@gPY*)N zmaiWExZNylzQXzQnm9dP&rlW>hf&wsg=98zrZhhp$Z$3%Kndl5UtNbaKlaPM^>r=P z!$`{0_D2gTNzhy6y&BGH94)>q(9G5UJN@Qf+Dm~W@H7#$wBcweJ4KI~Y16gdLUU1F zcEaaf+!7-XWKx+2u6fV(@QJ1>taM%wx@UeW8%5~K37}8 z%@=*)wk@15{4Ql6b<4MNL2|o^k3h@N!y)>JG%UnkZxFftH0YIsHd9m0*za?Xc8W?e zP2L1cb%16mFh5&hqHF_|9s{>nVaBYW5ci+cXM9nAl^5WYDiAIan{}2<_SyDko{Q45 z+co8U?pCCyhRBtYkq4vU@Eyfw7E0%aX?jruC^lNRdeae)Pf0>qt{OnFP zTBQff_`7XOBnSRU5-j_AIk$27a#wD$<)&Lu;<_MFVZEWtNiwtkCanx6qBpMb(Y8~t zaflJP*DYuG^5k4De{Cqoul=DD2lt7kR`Jj(Wd6)tAKI)Kt-XjyZe059RxPIRJ25V0 z0EediJ@PNM{;IZ2Y_1@F&qm(am%_*c{nJ4&4>P&Gs~>GdG|I0Tc(p6Y{YBI&9GF}aS-654LxsBDQ4a?iAdQEHL*%ZppahT4^`D5W= z=5FFz4EYx%V~VK#4nK8^sr1b&+v<(4wd#wBlovOCH#C6(w@pu z(@yeHvcT-8kAu;FSJZa{IjH780d!o_If*TilHF6zl?RyX46Z1A^KfaoD3sUqbL;Un zj(_1pjMcrKoBr=AS-!{ge_WV16uOsS`e8x2G|hQe)OudOay5f|SXUM|Of|NZS@D1|f^KlCqZT=SuM%!ka zrkdWYIn_mM={Wd1FXJbUQl@s4Wb&26jQOVgOppD^d79O85cfC4m1a42LT&azRCKAT zW!W*QDgP$JK2ZVpL&`#){$oy(8-BgHPA9u^wH|NzSiJHT>~~MUBbRDriK*#40?ZFL zh)!nxKERtBc%uBR=Ys{4WC^v2FxHKGeET12KC)$gSySqBeyi8}3iTs$TI}^6TP>45 zH5dtfR;o9z^I!GfnK;6hxt|f-y{pC)o_adqhj}T8D17yBS-2>~s?-&SF0(N%tI>mv;H) z-QCT)OW5K1T;o{q=f|FHozL6rmEv;|hIX-T$DhvS)9h#`&-65X&e?nOuFI0C8+}E= z`LOW3etrku5Ky{y9J3m(?JSa$-F75xiGYL8(T5)_VQv^<-Bmw^`TC1p&z-y#Irdg~ z)3q>8@7^w;yA~gX)dk)=nyh6)mj=Imyx1Ah74t*QX1MH9-ct1|J4`snEOu$$^5UT{ z1s%uNo^lP;Ef27O{=`#T_Bxrptav+K{)-hosrIF(VlQ|_%t;^0wLw`y(?nB?I9p|( z-hqruc2)6Pud><|&rEG*w@F_s8{br0>X=N8WZL?5Kef7=xWKT9s+wy(DvxJ9&NyX# zwmLv{qe`{?%BXfJ>F070Yj{rhJ&lbzw}m2T4!P*Z1h`nAR?mLKpPD)Vxgg*=#i=A9}OHm_xHMu<*Uk_JW?3Bp~9c}8<1{<7|%<|m* z5GKTg3VHF+Nhd zvAKJ>d3Vim!|RPXsb`wDwZ{SnY*kC@Kf&w)`$tF8xSAVt1#hY;KRjI9G~rfDZ7|*M zz`T;ugS$CCv$*P_2hXrB65+DDb?j3}&EBo_K4xGm<* zu~7b12g1==rRvQGyq&woDn`%ndAVxeD!F((#%a&b@MMjJgbV58oBdXfxoI<%8%KW+ zyxS^fDeuvf2J^zFDDIMy<_qcdo3uu{9TZTSNpp zChSf2O@KQK^KL?zQ-U};xmmJ!?|5jJd#4hsVl)Z3`=>KyCAYB}O|+DK$@%a>mXa{+ z5z~?0@@Vv%bUL^5^Z|iHrG)e^_ie~{x`Wo|*P5nZGB1J7cL1Y~{zllWj{g^?2_SK2 zsnPz4Z+F!dn0^lrVj<qOJgR6%sCPI0lt zye;&v$v6G!ju@L=P`WK9Fz&HvG|!H#dekSG^}Eu0TR(rq_F2gCrvZ#>xZW4Bq<^VI z-uv2Dx#_2%`C6&j)FtD&#x9Vf0P4!ihse6+aI1 z28|W>p6Yn%QuCpl!{ktGag!Iefi|af9AVF6%^ZdSH=CssbWK} zUH5xQ=wQ~-nK`c$0`rHOK2H^N$&6^FMiZgRpeA#zCuZ+#qW)d~tJBxzs8y(Uwyo3z zo*KyADeKSi^SgZ?t5oXJxU5fVfl~UhltZx^JI$;4zterdwJ&lO)*Pz?zvbs{NA6g6 z#S8cQhT}jqJg<6EkzY@`aSo#FtHc~d{)fs7eKgayru(ye4ByRfeG0bPN`HnHF5Mf7 z*o_5f2Ol0FT(7eiJh}e;`?8oq{EoCp4jm8E1M#R&$(Fxt)r?|gR-YvQ*SGkJGMOq` zQ>^lpW4p#gWGihgd5vlUEevAp7UPQ+UzK?tRiDEN_lfbPsZKV1HFG=Lm#$*KyrWlr z6J0p*)J!J0l-NITB!+Lh@ycd|)zNg{bRlp}|MkwH<*lsTK23v@ zTD>9r|4ZRp7yqyBIdW%dY`5{Ftp(FA{)aqt4|^Q#FnXB_Rwz9$PhbSGV6nS-W+#l$ zuVoj%Fkqt$s=^$iKZYDvS*+u_+i7{@oD+}B+Gj4dfBD3NdUD9UdyC2*ZqV;k+47&s zha`4u)7ZTgg=CZ-w+3X4i83~$E7rC6!^YIB{|eh3a_bvw78TCNh-*8Q`F@x>KYuqu zihMSl#kiurY}_+pUU*>R?!TQkiKtK@-*b;~1s4%4F>x_ncy3w7hBf-BQn=P#>DJ}q zs0?uUt(xkT7{FDrQN0ju=Jl;wTJCbDM#D*%4aZLSdmX07sfF17A7{V!&1RB=Y{2@X zGl{r0%CGa_omCtnZy<@c3pkR9vVr&=&-&4|fk$|TN_x%CTo?(MK%q9#Dj{{4ol+o= z%f#*@{6z)C!+P@H8Rn$s2$w^_?;4Jc+h|RE-M4%1p}Oi{6_RUGFW_?m>c8#?Kb#ct z6`|dTFH(Qzmmhhn^}6cE%Yh(9^YOED1rH`q>rkXL%7?UHFKn)yKI0jCWUS*I&;0e5 zo?Tm7d-JgY!+XI5^;sFihS}4-vDIWrH+mMb08b`ix)sPG6F!s)yR0UiTTa`5e+Q3x9^qfSF3uEj z>X!Z)lCty3L;u1b-upJa)VvXS*3U65Ql0fvi>sx2QBHk%g6Z`q1-ay8^T$I*ep=su zDQ56Nm4_}lFP!G6t!w{$<|to((`lR19zR3h0CsBtGo2La71%R0GW9h%)VjdGNaJ^l zDqAl>eJX5K)iv3o>tdZ`gydNaN1o<1J^Kqf%bQk z&F}OhYWZ}YvahYEdb-T_mYV%%&6mVqcAjG%Y*gnEb5Hh4n(^JuemjyNhc`FIE9~=e z3xChAD;?nGwc3wkS4>a(R}>jqU`u6&uO987B>0Sr)LQ_a;?|^7yzx$yp1}6G9%BjzZLmKq;J z*UlORYO?wKy6{6wGkvWRA%nt&B9#hxJ9p&Cx#-CsIcGK8)_T;Nl-tmaG>wXZb;qY? zIBYhic)L;*8XCWRxZ=X0eb?ylw74v$_ux%Emic1(iFLl%{X5~@C$BMrpxkM=yP7IQ#DlE=hk268>8=^a+tl8$_j7;iy?To|l#ZVH_;t0m#i8!OsspB#UJ{%L zs(8W^68Qh4EOW$~TwnY^5){fKzX5ZBUS9Ma5YA<}WrB{2OhOcpMFJ2K19*1IwoJ9E zFr0QK{=U;QAae~;o*&-x3XpUGHpnwvdWC>0_(=kVpN5YsPqU%%bFLq_uqDBXjJeXb zQvC9hQ=>nt{%Ur$m+L-Zd}JvwvBmug*VH{TgVGjbcU^ITKW>@lYO)m+*7l1zrR*!8 zEF?*cbjxG`x2#JCg#R`?z)e0n8#rcbHWy9GdGpkIi*V;AM6Hz@$y| zHpky!lH}$zHsT|on{8+mag1Hi;n(nK$EI2P&u@REX%$9yZL8Oh3F$eFNB#1jrbT}y!SAIrY3&&o@>8DN9UFeo9OTF4!C4&T5$W|}ik6(U zb&9J@FX)Ii-)wzl{K+GlVz$(g(BubqtpoPH)O_}PZrUc&k(YjJqkyZKL=W?9WDM1W zbG&zT`}*s0(c_N((qPX=^KH~js{xJLV6U@EtBIAIwV!5o`u#S3eY?U5+L+!!9~5r7M%>@= z+Ke#!4eD1e!=IEVLw-WH;4J!++*%bMbAkSUpLi+)AD!=9;o1#wdX}Km4*e#^N7Il; zb&cVi`Xj5nFF?^H!Us*(RO4+W3b^oMuhx$4)2*H+lVinCUx02iY2JkB-zQJDc=(X) z%}n&`Sn%itwGbT^(`u>bKc7gb3HYcsyvP*c=rZ~MX4l)zdth*fZDC8>B_(*EcbLqUY z#_o{!MIPmGPuos5Xm__9G`@W z1Jt32#GSm#^`4JQnfrmgx#{6)Ss3Nxe$X-`VhI|m`wUt?erfirS5y-SV>Lb6XE^-D z>qW~gyqaJ*jyC<1xYSG|G3aJ`w_xc!A>ZahqarbaC%WeK|3cSYyN6$5V-E-FX zU4kgVzk6R4$HNN`{kOOCz6IFlu{N=@-hD0nXL+dCRo3jlOd%j=1_}k#|4QvvwX6$2 z`OEE%FL&1ow*V@oX|w-iqxE1{jg*7O0=&6mI>3kf**SiQ_lZxXd*?TH3+pYd z$yFSh_oTweO$ZdQ*#RU3Sa1SaaBO79IB};+5VYeEUI5dx!Y-OXn9CHuAV8^jlpV%a zXQ}zJX<99GAr@{1j4nOy)ZG_eJb*#k$MFPgTMnL%skdAh{{8U~EHz}>PaS1CLZ z?B#B8H{2u0VU9#TXUWnVVVYu(Kh~$OVd3ViJGFf8aD$^t)~^Gk+<;s3*gty~&c_g~ z|A<8X9_CWWc9UgY93(S>L%KN(h zFH?KD{^erytP| z`KvNR-n--H_NRI>$EtxgD`yumuy5xpm>-I;0$e*A8Tbiilyr9+J1vhP4E@Hc7|CO@ zm_wJBS}w;)K*c9v?<0?Db|gXwX8l3rUN0>G}m1B zwuxO)9u6<(U8{*Dy<^&Go}$ri_GhbO|LnCaFjwgQ_5f9xd*%E$O<1_Z>dq}|*3*@Y z(v>&6qCc%xUUW{P6))yTPUBMt>La9E(^*QG9xZfENFj{ON&nK(2m_4<7seQa)V z!NpEsXus!35-U%QNp1O01I3=MYwT_-$r#d2B3ZEOgnt^SSuT!mRQ+aFdwrqIjIsWB zjt#h1XUbn~c|ClccP`Jj?mBx;t6PuJPjSqu>!{vgaLerD7v}Jt|0dOn6W<#<`4W*# zI7A0}8UlXpqo=_MWUO^;ieR;W(R*f^uuB%pEr?+Qj396B=11pep^0iwItjeR2Hl|q zQNRK5xy3Fi)ehk)F<@zb*Sb!~R7~D^RBCdYsO7L8uk%W4`ch-_rHJ*O~NlMynS7ZJUASk70~=bCMem zdu{W+J&C>%$wOjM$M;T#1@HN%N+5nwM~V z^;GmwxqZjHci-ipqU)L-%dz@Rr;VSK%CesMru5_IEuY@{YZgWaNf|s>Oq6S1i00~7 z8%@0tNKb#`lbYPgYBV(YBz^ktlw(?mv0i`=@7(3;4uSBF1x$?m5CPa6ISH#WHCdjV zj}ZOe;lOvM*U~mLTjYA45ILZJ)-NA*+d7%iQ_!zN=x$FYG=c*Ogr?$<3>Np9jto$% z;_>AakSt*flEKmJu(;2I-UOv@@DnQ_OP%ow2G6j#?7-)0Or!~-+VzbVEOb8{fJn~p z&z%eT{B9UR-$`~?TYNnNqNN2%jOe#uildO)y)|U{)?C=*K0F~Sj>L(oWHo|YV$XeXbSROMpf8@PnC6&z#}WQ-eWo+Wkv9e|_K^40?W=Kg}zM{azbKd02by(A5Ju zLm#W2XLZX9_PI%%YaCMu4bKOedsr|KC%KOIV12z`A`0YWXzX8gJRG0X9x}G7UA>ql z|I<9)^U+%G-9tLzw7l4suW^AlKv){ExYGK_dBu%7P=<|U9m`bS{P$95rNG>(ycqW7 zKsllN90)rasAP8*9*INjAdHhDLTaEF(>+(HfkIXb6I2^dlS7)?wLWn;M_GZhu(cNO z*(SiKSq2z$4U$7fv6jC#qW{8inIC_XsAKTw9?c6V{luxW!YN(h$ z+iIcPs);v0P;@7vt@zF{4~O!t_8dl<+~b8WQ;!$!y%x_pNFm|I2fhz^c6Ejn@ppLH z*H~Q^4Vo)Eugtdm?$1Tc3BL9BKIi1!OK|agw*{q=4c&0fuW8my7eLlYR1-292mGdpD+m1y%Y-k47$f$~ zw8H=fw$LHjQwPB>LKcQgp(0i)5p+!yN%U}Hq02crnQC^FADM=935+P9Xw97f=rcGP z84K*<$R0s9(}!yV03?Cw%^K-`oy%fuK&GZ~k$76*P(z)%HfiZ$d< zAw&*M0*4vej#vCelnU*>~Ubh~Xq3I8XRmDzMc6F%{ zJJ%0N2VN{2@V&od5B+$hosJJ*y)nFxU*09<^%?gg&I&>12*&Wd0&5Z!5BP;J3Zq?2 z3IN0U(l%)BD-r`jihwCt?lGDMi!I5|P-|lIQrKyZdQbZCycSc4miHEX%HZKoV0PDe z(2e?YDf24glF=$w@GQ1ci3=+^%P5te*0JYDJ1Ycn1=eLnTcy$5Jghlqj;n7}jsIH13nt~`WH zfj0-X$zfo78k5Bufo3g`$8xJ14apV;5L%1kK@J`ZxrYHe_`<{ANqksqiZnP7kaI|) zC1L(QhZl(**_FW2G8l0n04w0n4<6!q*3FHPQ;@?CLr4bjiz5`gqbh=kLs5`Yeo6fJ znX}bPY`~nI%$DZKcgO9SqpwZ*Nz z2*(iY;f~9iW5|i-Mns9v2bI1Jde?8GdluXM<*DnNtMcRXcEe;p?0_l`jPE_fEt7-o zN2Y6RWbws=IN-Xc$v51`omn#Tez5)@szAO>BVJw`d z^zP|>3ucN49u}4hwV9gZ#1&o(bHdP{wk+GMIm{9EYg2T6O@%30crw##85e$4=;A#EVIlSn*fEE93__m9=@hnLPrnK z!ZNJj`$4b#B^+OyfaDRFAAXRKNx5hoK!N#S_d;TOAw0PgcWs=e0|B|mX zWUjRx0(tWyIQkiq?v2Or1g(+sd|g8Wxiwy{tA zh4Pc1J|Bq?v^O_9A}_M@09HPZG6BK(Wonq6R-!aUWDnxs!$HYkBTv3K%-`lrK>= zNi}()WRS6Eq5Yk?g|cg?&t@_k6cZQVa;JnB>y+y8#dOMR^uo4~3`?N}4v~bgATkbK z?>HZqruDPm(t$3CCop40fe}<_gQ3-Z;I|+a1#H7WAN&@)-o%(KdMYYlvdxO26AH*r zNq7+q5?hu%yO|lUA#in9!Yq7+!Hn9qP3DeZdM_^YK+(y)2?FqhE_Q_6d9k&~A|oRa zct@`zQeS}=m(?r%S#}CB-y#W`-9?ckM`PUIEgR$sD|C{@;t(QDIDi+Gl(d*jgv%wV zh$j_vZP_#pYbd2vg%v0{*L**7_F#=7AqI^6FcyS!e9!E!yG(G-0s*0aFrkE-c8(?2 z{>4+6mTJ1X@ML4@Qf~K`Q-%=h8y2HErqCEUOl(mgM24K-!5{_%cC(GNo@ZMtGy+&8 zS!@n5Jrw**3*6Z`0Q@-UXIZfipPWDz$}lQ|mBuRz3U(16V;9DC%SjUWb2WS7L_=|Z zwK!=!0HR&JTLya)1_w*1@4y<;jER-oQS!toC1%8p3~@gE-r@@5OkeR2)9g zIFySm%T=dCae}xPAGa>Z&gGoA4z#OMhp$@^juH_P^=^vZZ7i0i>(j&&&Mi5zJZJev z0o$jmQeszji%A)n;fJt)6DZW50#C|~5gUT$o;vCQAj6CR{;$aZbKxXGy?rPVw2@92 zBTT3obZs{!3e@@Za?$qT5=rx?aIQWq7DEwN6|uBr3W6jNC1zSOn#D=`=tWpL!y
9>cB>6)++;dIv%hlV#j&)rJ>ASd{VY*YYWCeXoJ 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 literal 0 HcmV?d00001 diff --git a/src/SheetMe.Designer/Assets/sheetme.ico b/src/SheetMe.Designer/Assets/sheetme.ico new file mode 100644 index 0000000000000000000000000000000000000000..1dc66ea5e09ceeda9d53f80f1d40f54e4f77b764 GIT binary patch literal 92207 zcmZU)cUTkc^F6#NkkA51mljl%D!sP=3Q`25h)5SHB1rEL5)iOZR6sgG5dl$(fP%CD zN^got6+#V7TIdAw=J|cT|Gj%y*(jY)u`hT`AGXQMfp#Bp1Kl_+^DA_w8fSbDCf5*E@0{{|E zAb^GCfA$C%0PebifTw0A`b_la>8Yn;GPrTg{J*XLt#DfEo1K4&+kaMoxxNliKESs^ zHK6X=H?;wvDusdUOg$z*=X=8@000o}|E-`-pOX6kKrAr0rfnJOu--(IW@Y{5p!am+ zU-Q?7O>a}xX#>@bXARVamH`?I!W%G9 zyYE0m zb6#F@D%^b|C}KKhZU0*w>p;HB?0)wHXOr&>4}*{2l?9KdJ;Z;66HeI)P1g$6h@APn zS@Ff;9kaD)))Fl-u;k2B?S@jNpO5s;ueRhRcB;h}{1QKTF4p9?OsF`17tBO%_z!R= zCH@wX`^@E65K+oPH!s>a1-~||8+CKZ;p=fbEqC@I4zqMhRpa9w>pWcqo_VuA5wpWj(5<3I_rMxx&BzM^Uq|qne zc1fM}@PIj)08ds+eE)MGx|#oJOLNrUFwgLJq^0#WlR}%(yhqhKpF-T?RK(pJ0yMM2 z`ZcBJv!lYopXA9)e!rUQ*Z+m64>p?n{w6~h+1Lskqtk zJ;j`Q_2=E9Ut>dBy1u{V&${zjoMCfQ3O^Bhox2fvuEmExmgtstrKPH|#+g7FrDBLq z0+A;P_sPM_R>bL_LIcBW?PdY@o(#Jga?_eJ?e%91xJAor^c{syD4Z4R3Ml{CT+Ek5OLQl;CB%uIYBSnM)DT-dd_Q>6wg z+g~Kap4K03p#&78a4Cb)5^b~63;|y+%~sf-+wM45eE9Uibx8>2LCi(vvj?4PT-#-u z)?1yyo-2H-&hH(T8F2kI^-Ylt&u z>Q?)!S1RA)%UL)CE7(dsfpXfxrkNm1)h^HF*2Znh7l!WbX`BNbm~%L?FH8MnTDsV;y%vnF!{A&r;gY1jSZ4)y)Atm zs}%v;Ke6hOk#zu$V)lgp%Ja(2NIHa_=#Q5}=8va8%lJQ#5a7;@8|9pgJ4o(k5W^A< ziQEf>*!HciCq^c<25(F;F^)5(^F!Qh_O#FQ|6!QvKi-oc{^J9(^Zj9X zH`7bP{pa0PE|`!)(5n?vIiy+_Ya?|3QmyNsk>r~uY6sa#^$TN{dYOtZ7Z(q{n)mGd zV^XfA-;!{XVv@i3!_8qtt|
    I_T^lPg!w?dI!S{Sh6TvvgcYIu7`!k#D%I8$I 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캔버스 드롭 수신 — 팔레트 타입을 월드 좌표에 배치. +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 @@ + + + + +