초기 커밋: SheetMe 서식생성기 (P0~P5 완료 상태)

레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너.
기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49.

이 커밋에 함께 포함된 자격증명 분리:
- appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환
- 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외,
  csproj Debug 조건부 복사라 Release 산출물에 실리지 않음)
- ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경,
  미치환 플레이스홀더는 '미설정'으로 간주

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-11 17:20:22 +09:00
co-authored by Claude Fable 5
commit 16c07f48dc
102 changed files with 14210 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
namespace SheetMe.Core.Catalog;
/// <summary>
/// 팔레트 컨트롤 카탈로그 — 팔레트 목록·기본 크기·필드 생성 규칙의 단일 출처.
/// 속성 편집 스키마(PropertyDef)는 P3(인스펙터)에서 확장한다.
/// </summary>
public static class ControlRegistry
{
#region Member Fields
private static readonly List<ControlDescriptor> all = new()
{
new()
{
Type = "Label", DisplayName = "라벨", DefaultW = 120, DefaultH = 22,
Properties = new PropertyDef[]
{
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.MultilineText },
new() { Key = "TextAlign", Label = "정렬", Editor = PropEditorKind.Choice, Choices = new[] { "TopLeft", "TopCenter", "TopRight", "MiddleLeft", "MiddleCenter", "MiddleRight" } },
new() { Key = "ForeColor", Label = "글자색", Editor = PropEditorKind.Color },
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
},
},
new()
{
Type = "TextBox", DisplayName = "텍스트박스", DefaultW = 160, DefaultH = 22, CreatesField = true,
Properties = new PropertyDef[]
{
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
new() { Key = "Multiline", Label = "여러 줄", Editor = PropEditorKind.Toggle },
new() { Key = "TextAlign", Label = "정렬", Editor = PropEditorKind.Choice, Choices = new[] { "Left", "Center", "Right" } },
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
new() { Key = "InitialValue", Label = "초기값", Editor = PropEditorKind.Text },
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag },
new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text },
new() { Key = "RwdRsvWrdYon", Label = "상용구 사용", Editor = PropEditorKind.Toggle },
new() { Key = "RwdOrderAutYon", Label = "처방연동 상용구", Editor = PropEditorKind.Toggle },
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
new() { Key = "BorderStyle", Label = "테두리", Editor = PropEditorKind.Choice, Choices = new[] { "None", "FixedSingle", "Fixed3D" } },
},
},
new()
{
Type = "MaskedTextBox", DisplayName = "마스크입력", DefaultW = 160, DefaultH = 22, CreatesField = true,
Properties = new PropertyDef[]
{
new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Text },
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
},
},
new()
{
Type = "CheckBox", DisplayName = "체크박스", DefaultW = 110, DefaultH = 20, CreatesField = true, DefaultDataType = "bool",
Properties = new PropertyDef[]
{
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
new() { Key = "Checked", Label = "기본 체크", Editor = PropEditorKind.Toggle },
new() { Key = "Score", Label = "점수", Editor = PropEditorKind.Number },
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
},
},
new()
{
Type = "RadioButton", DisplayName = "라디오버튼", DefaultW = 110, DefaultH = 20, CreatesField = true,
Properties = new PropertyDef[]
{
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
new() { Key = "Checked", Label = "기본 선택", Editor = PropEditorKind.Toggle },
},
},
new()
{
Type = "ComboBox", DisplayName = "콤보박스", DefaultW = 140, DefaultH = 22, CreatesField = true,
Properties = new PropertyDef[]
{
new() { Key = "Items", Label = "선택지(줄바꿈 구분)", Editor = PropEditorKind.StringList },
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
new() { Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.Text },
},
},
new()
{
Type = "ListBox", DisplayName = "리스트박스", DefaultW = 140, DefaultH = 90, CreatesField = true,
Properties = new PropertyDef[]
{
new() { Key = "Items", Label = "항목(줄바꿈 구분)", Editor = PropEditorKind.StringList },
},
},
new()
{
Type = "CheckList", DisplayName = "체크리스트", DefaultW = 160, DefaultH = 90, CreatesField = true,
Properties = new PropertyDef[]
{
new() { Key = "Items", Label = "항목(줄바꿈 구분)", Editor = PropEditorKind.StringList },
},
},
new()
{
Type = "DateTimePicker", DisplayName = "날짜선택", DefaultW = 140, DefaultH = 22, CreatesField = true, DefaultDataType = "datetime",
Properties = new PropertyDef[]
{
new() { Key = "Format", Label = "형식", Editor = PropEditorKind.Choice, Choices = new[] { "Long", "Short", "Time", "Custom" } },
new() { Key = "CustomFormat", Label = "사용자 형식", Editor = PropEditorKind.Text },
},
},
new()
{
Type = "Panel", DisplayName = "패널", DefaultW = 240, DefaultH = 140, IsContainer = true,
Properties = new PropertyDef[]
{
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
new() { Key = "BorderStyle", Label = "테두리", Editor = PropEditorKind.Choice, Choices = new[] { "None", "FixedSingle", "Fixed3D" } },
},
},
new()
{
Type = "GroupBox", DisplayName = "그룹박스", DefaultW = 240, DefaultH = 140, IsContainer = true,
Properties = new PropertyDef[]
{
new() { Key = "Text", Label = "제목", Editor = PropEditorKind.Text },
},
},
new()
{
Type = "Line", DisplayName = "선", DefaultW = 200, DefaultH = 1,
Properties = new PropertyDef[]
{
new() { Key = "LineColor", Label = "선 색", Editor = PropEditorKind.Color },
},
},
new()
{
Type = "PictureBox", DisplayName = "이미지", DefaultW = 120, DefaultH = 90,
Properties = new PropertyDef[]
{
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
new() { Key = "SizeMode", Label = "크기 모드", Editor = PropEditorKind.Choice, Choices = new[] { "Normal", "StretchImage", "AutoSize", "CenterImage", "Zoom" } },
},
},
new()
{
Type = "CalcBox", DisplayName = "계산박스", DefaultW = 100, DefaultH = 22, CreatesField = true, DefaultDataType = "decimal",
Properties = new PropertyDef[]
{
new() { Key = "Formula", Label = "수식", Editor = PropEditorKind.MultilineText },
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
},
},
new()
{
Type = "Button", DisplayName = "버튼", DefaultW = 90, DefaultH = 26,
Properties = new PropertyDef[]
{
new() { Key = "Text", Label = "텍스트", Editor = PropEditorKind.Text },
new() { Key = "DataActionTag", Label = "액션 태그", Editor = PropEditorKind.DataActionTag },
new() { Key = "DataActionTagControl", Label = "액션 대상 컨트롤", Editor = PropEditorKind.Text },
new() { Key = "GetDataActionTagControl", Label = "입력 파라미터 컨트롤", Editor = PropEditorKind.Text },
new() { Key = "KeyboardShortcut", Label = "단축키", Editor = PropEditorKind.Text },
new() { Key = "BackColor", Label = "배경색", Editor = PropEditorKind.Color },
},
},
new()
{
Type = "DataTable", DisplayName = "데이터소스", DefaultW = 34, DefaultH = 34,
Properties = new PropertyDef[]
{
new() { Key = "Query", Label = "쿼리(SQL)", Editor = PropEditorKind.SqlQuery },
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
new() { Key = "ExcuteQuery", Label = "쿼리 실행", Editor = PropEditorKind.Toggle },
},
},
};
private static readonly Dictionary<string, ControlDescriptor> byType =
all.ToDictionary(d => d.Type, StringComparer.Ordinal);
#endregion
#region Properties
/// <summary>팔레트에 노출할 전체 컨트롤 목록</summary>
public static IReadOnlyList<ControlDescriptor> All => all;
#endregion
#region Methods
/// <summary>타입명으로 기술자 조회 — 미지원 타입(Placeholder 포함)은 null</summary>
public static ControlDescriptor? Find(string type)
=> byType.TryGetValue(type, out var descriptor) ? descriptor : null;
#endregion
}
/// <summary>팔레트 컨트롤 1종의 기술자</summary>
public sealed class ControlDescriptor
{
/// <summary>중립 타입명(LegacyTypeCatalog 와 공유하는 키)</summary>
public string Type { get; init; } = string.Empty;
/// <summary>팔레트 표시명(한글)</summary>
public string DisplayName { get; init; } = string.Empty;
/// <summary>드롭 시 기본 너비(px)</summary>
public double DefaultW { get; init; }
/// <summary>드롭 시 기본 높이(px)</summary>
public double DefaultH { get; init; }
/// <summary>컨테이너 여부(Panel/GroupBox — 자식 배치 가능)</summary>
public bool IsContainer { get; init; }
/// <summary>배치 시 데이터 필드를 만드는 입력 컨트롤인지</summary>
public bool CreatesField { get; init; }
/// <summary>필드 기본 데이터 타입</summary>
public string DefaultDataType { get; init; } = "string";
/// <summary>인스펙터 속성 편집 스키마(타입 전용 — 공통 X/Y/W/H/Id/폰트는 인스펙터가 별도 제공)</summary>
public IReadOnlyList<PropertyDef> Properties { get; init; } = Array.Empty<PropertyDef>();
}
/// <summary>속성 편집기 종류</summary>
public enum PropEditorKind
{
/// <summary>문자열</summary>
Text,
/// <summary>여러 줄 문자열</summary>
MultilineText,
/// <summary>숫자</summary>
Number,
/// <summary>참/거짓</summary>
Toggle,
/// <summary>선택지</summary>
Choice,
/// <summary>색(레거시 invariant 문자열)</summary>
Color,
/// <summary>문자열 목록(콤보 items 등 — 줄바꿈 구분 편집)</summary>
StringList,
/// <summary>데이터 태그 피커(bzDataInterface 메서드 — 자동 채움 원천)</summary>
DataInterfaceTag,
/// <summary>액션 태그 피커(EN_DataActionTyp — 더블클릭/버튼 액션)</summary>
DataActionTag,
/// <summary>SQL 쿼리 — 전용 편집기 창(치환 변수 삽입 지원)</summary>
SqlQuery,
}
/// <summary>인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집</summary>
public sealed class PropertyDef
{
/// <summary>PropBag 키(레거시 Property 이름)</summary>
public string Key { get; init; } = string.Empty;
/// <summary>표시 라벨(한글)</summary>
public string Label { get; init; } = string.Empty;
/// <summary>편집기 종류</summary>
public PropEditorKind Editor { get; init; }
/// <summary>Choice 편집기의 선택지</summary>
public string[]? Choices { get; init; }
}
@@ -0,0 +1,556 @@
namespace SheetMe.Core.Catalog;
/// <summary>
/// 레거시 태그 값 카탈로그 — 인스펙터 피커의 선택지.
/// 원본: [003]EMR\[002]UserControl\clsBaseUserControl.vb 의 EN_DataActionTyp(선언 순서 보존 — 값 의미상 순서 변경 금지),
/// [001]Common\[012]DataTag\M.CMM.DataTag\bzDataInterface.vb 의 Public Function 목록 (2026-07-16 추출).
/// 사이트 커스텀 태그는 텍스트 직접 입력으로 지원(피커는 선택 보조).
/// </summary>
public static class LegacyTagCatalog
{
/// <summary>액션 태그(EN_DataActionTyp) — 더블클릭/버튼 액션 종류</summary>
public static readonly string[] DataActionTags =
{
"None",
"DtrLicList",
"OkdInfList",
"ComPidList",
"YondoList",
"ImgeList",
"ChoiceData",
"AllCheck",
"ReturnUidNam",
"OprInfList",
"TPR",
"VisibleYon",
"DateVisibleYon",
"ReturnEmrData",
"ReturnAllEmrData",
"EmgMnsList",
"EmgLevTim",
"CodeFInd",
"EmgKTAS",
"NdsMst",
"UmlsMst",
"ComTrsDtm",
"PikPs1",
"ButtonData",
"OkdInfData",
"EmgTotalEmr",
"LabOdrList",
"PdeOdrList",
"ComOkdList",
"MakeDataList",
"CodList",
"PrgRcdList",
"PanelVisible",
"PanelUnVisible",
"MinusToPlus",
"TPRList",
"OrderList",
"BldOutList",
"BlmInfList",
"ReturnLabData",
"ReturnLevOrderList",
"SetVisibleStatus",
"HiraSearch",
"DataCopy_Btn",
"DataCopy_MtM",
"EndoOdrList",
"EndoOdrList2",
"LabOdrCodList",
"상병발생진단일",
"UnAllCheck",
"MaterialList",
"IcpMstList",
"UserLicList",
"SgaSearch",
"OkdInfEngData",
"OrderEngList",
"DataCopy_Chk",
"DataCopy_Chk_UidNam",
"DataCopy_Opt",
"PanelVisibility_Opt",
"ComAcpLev",
"ComAcpLev_O",
"ComAcpLev_O_Ver2",
"ComAcpLev_I",
"ComAcpLev_I_Ver2",
"ComAcpLev_I_Ver3",
"CowInfData",
"AllCheck_Opt",
"EmgOkdInfData",
"ChildIBWCalc",
"EmgDetList",
"OrderNameList",
"NurseList",
"EmrDataCopy",
"TextYorN",
"TextPlusMinus",
"SMSWebLink",
"RadCheckControl",
"NonOdrCodList",
"ZipFinder",
"OprInfList_Cht",
"ChkPatData",
"PatInfList",
"HiPassPatList",
"ChildIBWValue",
"DataTableRefresh",
"PathologyInfo",
"UserListTile_Doctor",
"UserListTile_Nurse_OPR",
"UserListTile_Nurse_ANE",
"UserListTile_Nurse_ASS",
"UserListTile_Nurse_CIR",
"UserListTile_nurse_SCR",
"UserListTile_Nurse_WRD",
"UserListTile_LAB",
"ReturnProgressRecord",
"RstInfList",
"TextConnect",
"DssOkdOpnLst",
"OprInfList_Multi",
"IndAccDetail",
"IndAccDetail_Chart",
"ChkDataCopy",
"ChkDataCopy_MtM",
"AneDtrLicList",
"PreOprInfList",
"PresentTime",
"SingleOprInfo",
"GetTerminology",
"ComTrsLev",
"Expanded",
"UnExpanded",
"EyeTestResultView",
"OkdInf_MainSub",
"UserAllLicList",
"DateTime_Calculation",
"EmrSelectData",
"DateTime_DateDiff_Day",
"ComPidListDate",
"AllCheckUnCheck",
"NonOdrCodList_WithDtl",
"EmrSelectData_Program",
"PdeOdrList_OdrNam",
"TPRList_VS_HW",
"ProgressRecord",
"XrayOdrCodList",
"OprOrderList",
"MakeNurseRecord_S077",
"OprOkdList",
"GCSCheck",
"ComboBox_COPY_ATOB",
"NonOdrCodList_Dep",
"EvalutaionList",
"DepMstList",
"NurUidList",
"PhaLicList",
"OnsetInsert",
"BldInfoDetail",
"GetEmrData",
"RefreshDataInterfaceTag",
"ICUInfList",
"OprUidList",
"QueryResultViewer",
"ComAcpLev_O_Ver3",
"OpDtrLicList",
"OrderCPTList",
"GroupResultCheck",
"OrderList_Usg",
"UnAllCheck_Opt",
};
/// <summary>데이터 태그(bzDataInterface 메서드) — 자동 채움 데이터 원천</summary>
public static readonly string[] DataInterfaceTags =
{
"ECT_로그인사용자싸인",
"ETC_건강보험증번호",
"ETC_건강보험증번호_Refer",
"ETC_로그인_근무부서",
"ETC_로그인_근무부서_사용자명",
"ETC_로그인_사용자ID",
"ETC_로그인_사용자ID_사용자명",
"ETC_로그인_사용자명",
"ETC_로그인_사용자연락처",
"ETC_로그인_원내연락처",
"ETC_로그인_의사면허번호",
"ETC_로그인_전문의번호",
"ETC_로그인_전문의번호_Refer",
"ETC_로그인_직급",
"ETC_로그인사용자_병동_수간호사",
"ETC_로그인사용자_병동_수간호사_싸인",
"ETC_로그인사용자_병동간호사목록_InitalDatatable",
"ETC_로그인사용자_서명",
"ETC_마취간호사List_InitialDatatable",
"ETC_마취과의사List_InitialDatatable",
"ETC_마취및소독간호사List_InitialDatatable",
"ETC_병리판독의사명",
"ETC_병리판독의사싸인",
"ETC_병리판독의사전문의번호",
"ETC_산재지정번호",
"ETC_소독간호사List_InitialDatatable",
"ETC_수술_내시경간호사목록_InitalDatatable",
"ETC_수술간호사목록",
"ETC_수술간호사목록_InitalDatatable",
"ETC_수술실간호사",
"ETC_수술일자_1_몇년",
"ETC_수술일자_1_몇년2자리",
"ETC_수술일자_2_몇월",
"ETC_수술일자_3_몇일",
"ETC_순환간호사List_InitialDatatable",
"ETC_승인의사",
"ETC_어시스트간호사List_InitialDatatable",
"ETC_영상의학과의사싸인",
"ETC_외출외박_확인간호사싸인",
"ETC_외출외박_확인원무과싸인",
"ETC_요양기관기호",
"ETC_요양기관명칭_병원대표자",
"ETC_요양기관명칭_병원로고",
"ETC_요양기관명칭_병원명",
"ETC_요양기관명칭_병원명_영문",
"ETC_요양기관명칭_병원장",
"ETC_요양기관명칭_병원장귀하",
"ETC_요양기관명칭_병원전화_팩스번호",
"ETC_요양기관명칭_병원전화번호",
"ETC_요양기관명칭_병원주소",
"ETC_요양기관명칭_병원주소_영문",
"ETC_요양기관명칭_병원직인",
"ETC_요양기관명칭_병원팩스번호",
"ETC_요양기관명칭_사업자등록번호",
"ETC_요양기관명칭_영문_병원전화_팩스번호",
"ETC_의사List_이름순",
"ETC_재해발생일",
"ETC_전체진료과_영문명",
"ETC_전체진료과_한글명",
"ETC_접속유저_부서번호",
"ETC_제한향균제_InitialDatatable",
"ETC_진단검사의사명",
"ETC_진단검사의사싸인",
"ETC_진단검사의사전문의번호",
"ETC_진료과별의사List_이름순",
"ETC_현재년도",
"ETC_현재시간",
"ETC_현재일시",
"ETC_현재일시_1_년",
"ETC_현재일시_2_월",
"ETC_현재일시_3_일",
"ETC_현재일시_4_몇시",
"ETC_현재일시_5_몇분",
"ETC_현재일시_MaskBox",
"ETC_현재일자",
"ETC_현재일자_영문",
"GetNurseEmrDT",
"GetNurseShtCod",
"GetNurseShtCod_GCRCH",
"GetNurseShtCod_GNBEDRO",
"GetNurseShtCod_SRH",
"GetOprInfDT",
"GetOprInfDT_OKDMain",
"OCM_BMI",
"OCM_BST",
"OCM_BST_LAST",
"OCM_Cosign_싸인",
"OCM_Cosign_의사명",
"OCM_DX",
"OCM_FollowUp",
"OCM_LMP",
"OCM_OPNAME",
"OCM_OPRCODNAM",
"OCM_SPO2",
"OCM_SPO2_LAST",
"OCM_STFDTR",
"OCM_User진료과",
"OCM_User진료과_Refer",
"OCM_User진료과_한글명칭",
"OCM_User진료의사명",
"OCM_User진료의사명_Refer",
"OCM_VITAL접수일시_LAST",
"OCM_간호정보조사지_과거력",
"OCM_감염분류_감염정보",
"OCM_감염정보",
"OCM_계산유형",
"OCM_내원유형",
"OCM_담당의사",
"OCM_담당의사_Refer",
"OCM_담당의사_담당의사코드",
"OCM_담당의사_연락처",
"OCM_담당의사_영문",
"OCM_담당의사_영문_Refer",
"OCM_담당의사면허번호",
"OCM_담당의사면허번호_Refer",
"OCM_담당의사싸인",
"OCM_담당의사싸인_Refer",
"OCM_담당의사전문의번호",
"OCM_담당의사전문의번호_Refer",
"OCM_당뇨식칼로리_List",
"OCM_당일식이",
"OCM_로그인_사용자싸인",
"OCM_맥박",
"OCM_맥박_LAST",
"OCM_머리둘레",
"OCM_머리둘레_LAST",
"OCM_몸무게",
"OCM_몸무게_LAST",
"OCM_발병일",
"OCM_병동",
"OCM_병동_Refer",
"OCM_병동_병실",
"OCM_병동_병실_베드",
"OCM_병실",
"OCM_병실_Refer",
"OCM_병실베드",
"OCM_병실인실",
"OCM_병실차액",
"OCM_보험유형",
"OCM_보험유형_Refer",
"OCM_부상병POA",
"OCM_부상병POA_Refer",
"OCM_부상병명",
"OCM_부상병명_Refer",
"OCM_부상병코드",
"OCM_부상병코드_Refer",
"OCM_비급여처방",
"OCM_비만도",
"OCM_상병명_가로",
"OCM_상병명_가로_Refer",
"OCM_상병명_상병코드",
"OCM_상병명_상병코드_Refer",
"OCM_상병명_세로",
"OCM_상병명_세로_Refer",
"OCM_상병명_영어_가로",
"OCM_상병명_영어_가로_Refer",
"OCM_상병명_영어_세로",
"OCM_상병명_영어_세로_Refer",
"OCM_상병코드_가로",
"OCM_상병코드_가로_Refer",
"OCM_상병코드_상병명",
"OCM_상병코드_상병명_Refer",
"OCM_상병코드_상병명_부상병",
"OCM_상병코드_상병명_부상병_Refer",
"OCM_상병코드_상병명_주상병",
"OCM_상병코드_상병명_주상병_Refer",
"OCM_상병코드_세로",
"OCM_상병코드_세로_Refer",
"OCM_수납일시",
"OCM_수납일시_Refer",
"OCM_수술_OprPatETC",
"OCM_수술마취의사",
"OCM_수술마취의사싸인",
"OCM_수술마취의사싸인_수술접수연동",
"OCM_수술명칭",
"OCM_수술부위",
"OCM_수술상병",
"OCM_수술소독간호사",
"OCM_수술일자",
"OCM_수술일자_Refer",
"OCM_수술일자_마지막수술",
"OCM_수술주상병",
"OCM_수술진단명",
"OCM_수술집도의",
"OCM_수술집도의_그룹진료과",
"OCM_수술집도의_싸인",
"OCM_수술집도의_진료과",
"OCM_수술처치",
"OCM_신환_초진일",
"OCM_신환_초진일_Refer",
"OCM_심사_부상병명",
"OCM_심사_부상병코드",
"OCM_심사_주상병명",
"OCM_심사_주상병코드",
"OCM_알러지",
"OCM_알러지_List",
"OCM_알러지_기타",
"OCM_알러지_약물",
"OCM_알러지_음식",
"OCM_알러지_조영제",
"OCM_영상촬영이력",
"OCM_영상촬영이력_Refer",
"OCM_외래내원일시",
"OCM_외래내원일시_Refer",
"OCM_외래내원일자",
"OCM_외래내원일자_Refer",
"OCM_외래내원일자_영문",
"OCM_외래실진료일",
"OCM_외래실진료일_refer",
"OCM_외래실진료일_과목별",
"OCM_외래실진료일수",
"OCM_외래실진료일수_Refer",
"OCM_외출예상시작일시",
"OCM_외출예상종료일시",
"OCM_외출외박신청일",
"OCM_외출외박종료일",
"OCM_응급증상여부_NO",
"OCM_응급증상여부_YES",
"OCM_의사퇴원예고일시",
"OCM_임신주기",
"OCM_입실시간",
"OCM_입원과",
"OCM_입원과_Refer",
"OCM_입원과_한글명칭",
"OCM_입원내원일시",
"OCM_입원내원일시_Refer",
"OCM_입원내원일자",
"OCM_입원내원일자_Refer",
"OCM_입원시간",
"OCM_입원시간_Refer",
"OCM_입원의사",
"OCM_입원의사_Refer",
"OCM_입원일시",
"OCM_입원일시_Refer",
"OCM_입원일시_영문",
"OCM_입원일자_낮병동",
"OCM_입원전환일시",
"OCM_입원전환일시_Refer",
"OCM_입원전환일자",
"OCM_입원전환일자_Refer",
"OCM_입통원구분_입원",
"OCM_입통원구분_입원_Refer",
"OCM_입통원구분_통원",
"OCM_입통원구분_통원_Refer",
"OCM_재원일수",
"OCM_재원일수_Refer",
"OCM_재원일수_낮병동",
"OCM_재원일수_단입법",
"OCM_적용진료과",
"OCM_적용진료의사",
"OCM_전과",
"OCM_전과내역",
"OCM_전과내역_Refer",
"OCM_전과일시",
"OCM_전과일자",
"OCM_조정체중_LAST",
"OCM_주상병POA",
"OCM_주상병POA_Refer",
"OCM_주상병명",
"OCM_주상병명_Refer",
"OCM_주상병명_영어",
"OCM_주상병명_영어_Refer",
"OCM_주상병일자",
"OCM_주상병일자_Refer",
"OCM_주상병코드",
"OCM_주상병코드_Refer",
"OCM_주상병특정기호",
"OCM_주상병특정기호_Refer",
"OCM_진단구분_상병명",
"OCM_진단구분_상병코드_상병명",
"OCM_진단구분_상병코드_상병명_Refer",
"OCM_진단서의사_영문",
"OCM_진단서의사_영문_Refer",
"OCM_진단서의사면허번호",
"OCM_진단서의사면허번호_Refer",
"OCM_진단서의사명",
"OCM_진단서의사명_Refer",
"OCM_진단서의사명_외래",
"OCM_진단서의사세부전문의번호",
"OCM_진단서의사싸인",
"OCM_진단서의사싸인_Refer",
"OCM_진단서의사전문의번호",
"OCM_진단서의사전문의번호_Refer",
"OCM_진단서전문과목",
"OCM_진단서전문과목_Refer",
"OCM_진단서전문그룹과목",
"OCM_진단서전문그룹과목_Refer",
"OCM_진단서청구진료과목_로그인",
"OCM_진료과",
"OCM_진료과_Refer",
"OCM_진료과_그룹코드",
"OCM_진료과_영어",
"OCM_진료과_전화번호",
"OCM_진료과대외명칭",
"OCM_진료과대외명칭_Refer",
"OCM_진료과약어명칭",
"OCM_진료기간",
"OCM_진료기간_Refer",
"OCM_진료기간_진료과",
"OCM_진료기간_진료과_Refer",
"OCM_청구진료과목",
"OCM_청구진료과목_Refer",
"OCM_체온",
"OCM_체온_LAST",
"OCM_초진일_발병일",
"OCM_초진일_발병일_Refer",
"OCM_최초내원일",
"OCM_키",
"OCM_키_LAST",
"OCM_퇴원과",
"OCM_퇴원과_Refer",
"OCM_퇴원과_한글명칭",
"OCM_퇴원분석_수술처치명",
"OCM_퇴원시간",
"OCM_퇴원시간_Refer",
"OCM_퇴원약",
"OCM_퇴원약_New",
"OCM_퇴원약_Refer",
"OCM_퇴원약_명칭",
"OCM_퇴원약_용량",
"OCM_퇴원약_용법",
"OCM_퇴원약_일수",
"OCM_퇴원약_코드",
"OCM_퇴원약_횟수",
"OCM_퇴원예고일시",
"OCM_퇴원예고재원일수",
"OCM_퇴원예정일시",
"OCM_퇴원의사",
"OCM_퇴원의사_Refer",
"OCM_퇴원일시",
"OCM_퇴원일시_Refer",
"OCM_퇴원일시_영문",
"OCM_퇴원일자_낮병동",
"OCM_표준체중_LAST",
"OCM_혈압",
"OCM_혈압_BPD",
"OCM_혈압_BPS",
"OCM_혈압_LAST",
"OCM_혈액형",
"OCM_협진과",
"OCM_협진과_협진의",
"OCM_호흡",
"OCM_호흡_LAST",
"PAT_BMI",
"PAT_IBW",
"PAT_IBW_퍼센트",
"PAT_건보_급여_세대주명",
"PAT_건보세대주명",
"PAT_국적",
"PAT_나이",
"PAT_나이_00Months_00Days",
"PAT_나이_00개월_00일",
"PAT_나이_00개월_일",
"PAT_나이_개월_00일",
"PAT_나이_개월수",
"PAT_나이_세",
"PAT_도로명주소",
"PAT_보호자연락처",
"PAT_생년월일",
"PAT_생년월일_영문",
"PAT_생년월일_영문_일월년도",
"PAT_성별",
"PAT_성별_나이",
"PAT_성별_남",
"PAT_성별_여",
"PAT_실제생년월일",
"PAT_여권번호",
"PAT_연락처",
"PAT_연락처_뒷4자리",
"PAT_영문도로명주소",
"PAT_영문이름",
"PAT_우편번호",
"PAT_이름",
"PAT_자택전화번호",
"PAT_장애등급",
"PAT_주민번호",
"PAT_주민번호_Blind",
"PAT_주민번호_Dash",
"PAT_주민번호_SexTyp_Blind",
"PAT_주민번호_SexTyp_BlindStar",
"PAT_지번주소",
"PAT_차트번호",
"PAT_한글성별",
"PAT_협력업체",
"PAT_휴대전화번호",
"PAT_휴대전화번호_뒷4자리",
"퇴원약DT",
};
}
+88
View File
@@ -0,0 +1,88 @@
namespace SheetMe.Core.Models;
/// <summary>
/// 배치된 컨트롤 1개 — 레거시 XML의 &lt;Object&gt; 노드 1개에 대응.
/// 모든 Property 는 <see cref="Props"/>에 원본 순서로 무손실 보존되고,
/// 편집 편의를 위해 Location/Size 만 <see cref="Bounds"/>로 리프트한다
/// (편집 중 Props 의 Location/Size 는 stale — 쓰기 시 Bounds 값으로 동기화된다).
/// </summary>
public sealed class ControlElement
{
#region Properties
/// <summary>중립 타입명 — ControlRegistry/LegacyTypeCatalog 의 키. 미지원 타입은 "Placeholder"</summary>
public string Type { get; set; } = string.Empty;
/// <summary>컨트롤 이름(name 어트리뷰트) — 레거시 SctObjNam 등가, 문서 내 유일해야 함</summary>
public string Id { get; set; } = string.Empty;
/// <summary>원본 AssemblyQualifiedName — 읽기 시 원문 보존, 신규 생성 시 카탈로그에서 채움</summary>
public string? LegacyAqn { get; set; }
/// <summary>원본에 name 어트리뷰트가 있었는지 (왕복 보존)</summary>
public bool HasNameAttr { get; set; } = true;
/// <summary>원본에 children 어트리뷰트가 있었는지 (Control 이면 "Controls")</summary>
public bool HasChildrenAttr { get; set; } = true;
/// <summary>Object 어트리뷰트 DisplaySequence 값 (레거시 writer 는 항상 0 기록)</summary>
public int DisplaySequence { get; set; }
/// <summary>부모 기준 배치(px) — Props 의 Location/Size 리프트</summary>
public LayoutRect Bounds { get; set; } = new();
/// <summary>원본 Props 에 Location 이 있었는지 — 쓰기 시 생략/기록 판단</summary>
public bool HasLocation { get; set; }
/// <summary>원본 Props 에 Size 가 있었는지 — 쓰기 시 생략/기록 판단</summary>
public bool HasSize { get; set; }
/// <summary>전체 속성 가방(원본 순서 보존) — Location/Size 포함(쓰기 시 Bounds 로 동기화)</summary>
public PropBag Props { get; set; } = new();
/// <summary>자식 컨트롤 — 레거시 Controls 컬렉션 순서 그대로(index 0 = 최상위 z-order)</summary>
public List<ControlElement> Children { get; set; } = new();
/// <summary>디자이너 편집 잠금(직렬화 안 함 — 편집 전용 상태)</summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool Locked { get; set; }
/// <summary>디자이너 임시 숨김(직렬화 안 함 — 편집 전용 상태, 레거시 Visible 과 별개)</summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool Hidden { get; set; }
/// <summary>디자이너 그룹 식별자(직렬화 안 함 — 편집 전용 상태)</summary>
[System.Text.Json.Serialization.JsonIgnore]
public string? GroupId { get; set; }
#endregion
#region Methods
/// <summary>텍스트 속성(Text) 편의 접근 — 없으면 빈 문자열</summary>
public string GetText() => Props.GetText("Text") ?? string.Empty;
/// <summary>깊은 복제(자식·속성 포함)</summary>
public ControlElement Clone()
{
var clone = new ControlElement
{
Type = Type,
Id = Id,
LegacyAqn = LegacyAqn,
HasNameAttr = HasNameAttr,
HasChildrenAttr = HasChildrenAttr,
DisplaySequence = DisplaySequence,
Bounds = Bounds.Clone(),
HasLocation = HasLocation,
HasSize = HasSize,
Props = Props.Clone(),
Locked = Locked,
Hidden = Hidden,
GroupId = GroupId,
};
foreach (var child in Children)
{
clone.Children.Add(child.Clone());
}
return clone;
}
#endregion
}
+40
View File
@@ -0,0 +1,40 @@
namespace SheetMe.Core.Models;
/// <summary>
/// 서식 문서 — 직렬화(레거시 XML/JSON)의 단위. 페이지 목록과 메타데이터를 갖는다.
/// FormId/Title 은 XML 본문이 아닌 DB(E_ShtMst)에서 오는 메타데이터다.
/// </summary>
public sealed class FormDocument
{
#region Properties
/// <summary>서식 코드 — E_ShtMst.ShtCod (파일 기반일 때는 파일명 유래)</summary>
public string FormId { get; set; } = string.Empty;
/// <summary>서식 명칭 — E_ShtMst.ShtKorNam</summary>
public string Title { get; set; } = string.Empty;
/// <summary>페이지 목록(순서 = 페이지 순서)</summary>
public List<FormPage> Pages { get; set; } = new();
/// <summary>부가 메타(출처/감사/읽기 경고)</summary>
public FormMeta Meta { get; set; } = new();
#endregion
#region Methods
/// <summary>깊은 복제 — Undo 스냅샷용(편집 전용 상태 포함)</summary>
public FormDocument Clone()
{
var clone = new FormDocument
{
FormId = FormId,
Title = Title,
Meta = Meta.Clone(),
};
foreach (var page in Pages)
{
clone.Pages.Add(page.Clone());
}
return clone;
}
#endregion
}
+32
View File
@@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
namespace SheetMe.Core.Models;
/// <summary>서식 문서 부가 메타 — 출처·감사 정보와 읽기 시 수집된 경고.</summary>
public sealed class FormMeta
{
#region Properties
/// <summary>레거시 가져오기 출처 디자인 키 — E_SdgMst.SdgKey</summary>
public decimal? SourceSdgKey { get; set; }
/// <summary>최종 수정자</summary>
public string? UpdatedBy { get; set; }
/// <summary>최종 수정일시(yyyyMMddHHmmss)</summary>
public string? UpdatedAt { get; set; }
/// <summary>읽기(파싱) 중 수집된 경고 — 직렬화하지 않음</summary>
[JsonIgnore]
public List<string> ReadWarnings { get; } = new();
#endregion
#region Methods
/// <summary>복제(경고 제외)</summary>
public FormMeta Clone() => new()
{
SourceSdgKey = SourceSdgKey,
UpdatedBy = UpdatedBy,
UpdatedAt = UpdatedAt,
};
#endregion
}
+40
View File
@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
namespace SheetMe.Core.Models;
/// <summary>
/// 서식 페이지 1장 — 레거시의 MDesignerHost 루트 &lt;Object&gt; 1개에 대응.
/// 루트 노드 자체를 <see cref="Root"/>(ControlElement)로 보존해 루트 속성까지 무손실 왕복한다.
/// </summary>
public sealed class FormPage
{
#region Properties
/// <summary>페이지 루트(MDesignerHost) — 자식이 페이지의 컨트롤들</summary>
public ControlElement Root { get; set; } = new();
/// <summary>페이지의 컨트롤 목록(루트의 자식) — index 0 = 최상위 z-order(레거시 규약)</summary>
[JsonIgnore]
public List<ControlElement> Controls => Root.Children;
/// <summary>페이지 너비(px) — 루트 Size 리프트, 레거시 기본 720</summary>
[JsonIgnore]
public double Width
{
get => Root.Bounds.W > 0 ? Root.Bounds.W : 720;
set => Root.Bounds.W = value;
}
/// <summary>페이지 높이(px) — 루트 Size 리프트, 레거시 기본 856</summary>
[JsonIgnore]
public double Height
{
get => Root.Bounds.H > 0 ? Root.Bounds.H : 856;
set => Root.Bounds.H = value;
}
#endregion
#region Methods
/// <summary>깊은 복제</summary>
public FormPage Clone() => new() { Root = Root.Clone() };
#endregion
}
+26
View File
@@ -0,0 +1,26 @@
namespace SheetMe.Core.Models;
/// <summary>
/// 배치 사각형 — 부모(페이지 루트 또는 컨테이너) 기준 상대 좌표(px, WinForms px = WPF DIP 1:1).
/// </summary>
public sealed class LayoutRect
{
#region Properties
/// <summary>부모 기준 X(px)</summary>
public double X { get; set; }
/// <summary>부모 기준 Y(px)</summary>
public double Y { get; set; }
/// <summary>너비(px)</summary>
public double W { get; set; }
/// <summary>높이(px)</summary>
public double H { get; set; }
#endregion
#region Methods
/// <summary>복제</summary>
public LayoutRect Clone() => new() { X = X, Y = Y, W = W, H = H };
#endregion
}
@@ -0,0 +1,84 @@
namespace SheetMe.Core.Models;
/// <summary>
/// 레거시 서식 XML의 Property 값 변형(variant) 기본형.
/// 레거시 직렬화기(clsBasicDesignerLoader.WriteValue)가 만들어내는 5가지 형태 + 빈 값(Null)을
/// 원형 그대로 보존해 무손실 왕복을 보장한다.
/// </summary>
public abstract class LegacyPropValue
{
/// <summary>텍스트 값 — &lt;Property&gt;text&lt;/Property&gt; (TypeConverter invariant 문자열)</summary>
public sealed class TextValue : LegacyPropValue
{
/// <summary>속성 문자열 값</summary>
public string Value { get; set; } = string.Empty;
public TextValue() { }
public TextValue(string value) => Value = value;
public override LegacyPropValue Clone() => new TextValue(Value);
}
/// <summary>빈 값 — &lt;Property /&gt; 또는 내용 없는 요소 (로더에서 null 로 설정됨)</summary>
public sealed class NullValue : LegacyPropValue
{
public override LegacyPropValue Clone() => new NullValue();
}
/// <summary>중첩 속성 — Content 직렬화 속성의 자식 &lt;Property&gt; 목록 (예: DataBindings)</summary>
public sealed class NestedValue : LegacyPropValue
{
/// <summary>자식 Property 가방</summary>
public PropBag Children { get; set; } = new();
public override LegacyPropValue Clone() => new NestedValue { Children = Children.Clone() };
}
/// <summary>컬렉션 항목 — Content IList 속성의 &lt;Item type="AQN"&gt; 목록 (예: ComboBox Items)</summary>
public sealed class ItemsValue : LegacyPropValue
{
/// <summary>항목 목록</summary>
public List<LegacyItem> Items { get; set; } = new();
public override LegacyPropValue Clone()
{
var clone = new ItemsValue();
foreach (var item in Items)
{
clone.Items.Add(new LegacyItem { Aqn = item.Aqn, Value = item.Value.Clone() });
}
return clone;
}
}
/// <summary>바이너리 값 — &lt;Binary&gt;base64&lt;/Binary&gt; (BinaryFormatter/Byte[] 직렬화 잔재, verbatim 보존)</summary>
public sealed class BinaryValue : LegacyPropValue
{
/// <summary>base64 원문</summary>
public string Base64 { get; set; } = string.Empty;
public override LegacyPropValue Clone() => new BinaryValue { Base64 = Base64 };
}
/// <summary>컴포넌트 참조 — &lt;Reference name="..." /&gt; (같은 호스트 내 사이트 참조)</summary>
public sealed class ReferenceValue : LegacyPropValue
{
/// <summary>참조 대상 컴포넌트 이름</summary>
public string Name { get; set; } = string.Empty;
public override LegacyPropValue Clone() => new ReferenceValue { Name = Name };
}
/// <summary>깊은 복제</summary>
public abstract LegacyPropValue Clone();
}
/// <summary>Content IList 속성의 항목 1개 — &lt;Item type="AQN"&gt;값&lt;/Item&gt;</summary>
public sealed class LegacyItem
{
/// <summary>항목 타입의 AssemblyQualifiedName 원문</summary>
public string Aqn { get; set; } = string.Empty;
/// <summary>항목 값(대개 TextValue)</summary>
public LegacyPropValue Value { get; set; } = new LegacyPropValue.NullValue();
}
+102
View File
@@ -0,0 +1,102 @@
using System.Collections;
namespace SheetMe.Core.Models;
/// <summary>
/// 순서 보존 속성 가방 — 레거시 XML의 &lt;Property&gt; 목록을 원본 순서 그대로 담는다.
/// 값 갱신 시 원래 위치를 유지하고, 새 키는 뒤에 추가되어 왕복(diff) 안정성을 보장한다.
/// </summary>
public sealed class PropBag : IEnumerable<KeyValuePair<string, LegacyPropValue>>
{
#region Member Fields
private readonly List<string> keys = new();
private readonly Dictionary<string, LegacyPropValue> map = new(StringComparer.Ordinal);
#endregion
#region Properties
/// <summary>속성 개수</summary>
public int Count => keys.Count;
/// <summary>속성 이름 목록(원본 순서)</summary>
public IReadOnlyList<string> Keys => keys;
#endregion
#region Methods
/// <summary>속성 값 조회 — 없으면 null</summary>
public LegacyPropValue? Get(string name)
=> map.TryGetValue(name, out var value) ? value : null;
/// <summary>텍스트 속성 값 조회 — TextValue 가 아니면 null</summary>
public string? GetText(string name)
=> Get(name) is LegacyPropValue.TextValue text ? text.Value : null;
/// <summary>속성 존재 여부</summary>
public bool Contains(string name) => map.ContainsKey(name);
/// <summary>속성 설정 — 기존 키는 위치 유지, 새 키는 뒤에 추가</summary>
public void Set(string name, LegacyPropValue value)
{
if (!map.ContainsKey(name))
{
keys.Add(name);
}
map[name] = value;
}
/// <summary>텍스트 속성 설정</summary>
public void SetText(string name, string value)
=> Set(name, new LegacyPropValue.TextValue(value));
/// <summary>속성 제거</summary>
public bool Remove(string name)
{
if (!map.Remove(name))
{
return false;
}
keys.Remove(name);
return true;
}
/// <summary>지정 키 바로 앞에 새 키 삽입 — 기준 키가 없으면 뒤에 추가</summary>
public void InsertBefore(string anchorName, string name, LegacyPropValue value)
{
if (map.ContainsKey(name))
{
map[name] = value;
return;
}
var index = keys.IndexOf(anchorName);
if (index < 0)
{
keys.Add(name);
}
else
{
keys.Insert(index, name);
}
map[name] = value;
}
/// <summary>깊은 복제</summary>
public PropBag Clone()
{
var clone = new PropBag();
foreach (var key in keys)
{
clone.Set(key, map[key].Clone());
}
return clone;
}
public IEnumerator<KeyValuePair<string, LegacyPropValue>> GetEnumerator()
{
foreach (var key in keys)
{
yield return new KeyValuePair<string, LegacyPropValue>(key, map[key]);
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
#endregion
}
@@ -0,0 +1,145 @@
using System.Xml;
namespace SheetMe.Core.Serialization;
/// <summary>Spread 셀 1개(텍스트/스팬)</summary>
public sealed class SpreadCellInfo
{
/// <summary>행 인덱스(0-base)</summary>
public int Row { get; init; }
/// <summary>열 인덱스(0-base)</summary>
public int Col { get; init; }
/// <summary>행 스팬(기본 1)</summary>
public int RowSpan { get; init; } = 1;
/// <summary>열 스팬(기본 1)</summary>
public int ColSpan { get; init; } = 1;
/// <summary>셀 텍스트</summary>
public string Text { get; init; } = string.Empty;
}
/// <summary>Spread 격자 파싱 결과 — 렌더에 필요한 최소 정보</summary>
public sealed class SpreadGridInfo
{
/// <summary>기본 행 높이(px) — FarPoint 기본 20</summary>
public double DefaultRowHeight { get; set; } = 20;
/// <summary>기본 열 너비(px) — FarPoint 기본 62</summary>
public double DefaultColWidth { get; set; } = 62;
/// <summary>행별 명시 높이(index → px)</summary>
public Dictionary<int, double> RowHeights { get; } = new();
/// <summary>열별 명시 너비(index → px)</summary>
public Dictionary<int, double> ColWidths { get; } = new();
/// <summary>텍스트 있는 셀 목록</summary>
public List<SpreadCellInfo> Cells { get; } = new();
/// <summary>셀 스팬 목록(텍스트 없어도 병합 표시)</summary>
public List<SpreadCellInfo> Spans { get; } = new();
/// <summary>행 높이 조회</summary>
public double RowHeightOf(int row) => RowHeights.TryGetValue(row, out var h) ? h : DefaultRowHeight;
/// <summary>열 너비 조회</summary>
public double ColWidthOf(int col) => ColWidths.TryGetValue(col, out var w) ? w : DefaultColWidth;
}
/// <summary>
/// FarPoint Spread 직렬화 XML(E_SpdMst.SpdDesign) 파서 — 렌더용 최소 추출.
/// 근거: 실DB S999/P062 샘플(기본 골격) + FarPoint 표준 직렬화 구조.
/// 셀 데이터(&lt;Cell Row Column&gt;)와 스팬(CellRange)은 실물 부재로 합성 샘플 검증 —
/// 셀 있는 실서식 확보 시 재검증 필요(주석 유지).
/// </summary>
public static class FarPointSpreadParser
{
#region Methods
/// <summary>SpdDesign XML 파싱 — 실패 시 null(호출부가 자리표시 렌더 유지)</summary>
public static SpreadGridInfo? Parse(string spdDesignXml)
{
try
{
var xml = LegacyXmlSerializer.LoadLenient(spdDesignXml);
var info = new SpreadGridInfo();
// 축 크기 — Presentation//AxisModels 의 Row(세로 축=행 높이)/Column(가로 축=열 너비)
foreach (XmlElement axis in xml.SelectNodes("//AxisModels/Row")!)
{
ParseAxis(axis, info.RowHeights, size => info.DefaultRowHeight = size);
}
foreach (XmlElement axis in xml.SelectNodes("//AxisModels/Column")!)
{
ParseAxis(axis, info.ColWidths, size => info.DefaultColWidth = size);
}
// 셀 스팬 — SpanModel 하위 CellRange(Row/Column/RowCount/ColumnCount)
foreach (XmlElement range in xml.SelectNodes("//SpanModel//CellRange")!)
{
info.Spans.Add(new SpreadCellInfo
{
Row = IntAttr(range, "Row"),
Col = IntAttr(range, "Column"),
RowSpan = Math.Max(1, IntAttr(range, "RowCount", 1)),
ColSpan = Math.Max(1, IntAttr(range, "ColumnCount", 1)),
});
}
// 셀 텍스트 — Data 섹션 DataArea 하위 <Cell Row= Column=> 의 텍스트
foreach (XmlElement cell in xml.SelectNodes("//Data//DataArea//Cell")!)
{
var text = cell.InnerText.Trim();
if (text.Length == 0)
{
continue;
}
info.Cells.Add(new SpreadCellInfo
{
Row = IntAttr(cell, "Row"),
Col = IntAttr(cell, "Column"),
Text = text,
});
}
return info;
}
catch
{
return null;
}
}
/// <summary>축 Items 파싱 — Item(index, Size). index=-1 은 기본 크기 지정</summary>
private static void ParseAxis(XmlElement axis, Dictionary<int, double> sizes, Action<double> setDefault)
{
var defaultAttr = axis.GetAttribute("defaultSize");
if (double.TryParse(defaultAttr, out var defaultSize) && defaultSize > 0)
{
setDefault(defaultSize);
}
foreach (XmlElement item in axis.SelectNodes("Items/Item")!)
{
var index = IntAttr(item, "index", -1);
var sizeNode = item.SelectSingleNode("Size");
if (sizeNode is null || !double.TryParse(sizeNode.InnerText, out var size) || size <= 0)
{
continue;
}
if (index < 0)
{
setDefault(size);
}
else
{
sizes[index] = size;
}
}
}
private static int IntAttr(XmlElement element, string name, int fallback = 0)
=> int.TryParse(element.GetAttribute(name), out var value) ? value : fallback;
#endregion
}
@@ -0,0 +1,233 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using SheetMe.Core.Models;
namespace SheetMe.Core.Serialization;
/// <summary>
/// JSON 직렬화기 — 부 포맷(파일 내보내기/백업, 추후 주 포맷 승격 대비).
/// 모델 트리를 있는 그대로 담아 레거시 XML 과 동일한 정보량을 유지한다.
/// </summary>
public sealed class FormJsonSerializer : IDesignSerializer
{
#region Member Fields
private static readonly JsonSerializerOptions options = CreateOptions();
#endregion
#region Methods
/// <summary>JSON 을 서식 문서로 역직렬화</summary>
public FormDocument Read(string content)
=> JsonSerializer.Deserialize<FormDocument>(content, options)
?? throw new InvalidDataException("JSON 서식 문서를 해석할 수 없습니다.");
/// <summary>서식 문서를 JSON 으로 직렬화</summary>
public string Write(FormDocument document)
=> JsonSerializer.Serialize(document, options);
private static JsonSerializerOptions CreateOptions()
{
var opts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
opts.Converters.Add(new PropBagJsonConverter());
opts.Converters.Add(new LegacyPropValueJsonConverter());
return opts;
}
#endregion
}
/// <summary>PropBag JSON 컨버터 — 순서 보존을 위해 [{ "n": 이름, "v": 값 }] 배열로 직렬화</summary>
public sealed class PropBagJsonConverter : JsonConverter<PropBag>
{
public override PropBag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var bag = new PropBag();
if (reader.TokenType != JsonTokenType.StartArray)
{
throw new JsonException("PropBag 은 배열이어야 합니다.");
}
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException("PropBag 항목은 객체여야 합니다.");
}
string? name = null;
LegacyPropValue? value = null;
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
var propName = reader.GetString();
reader.Read();
switch (propName)
{
case "n":
name = reader.GetString();
break;
case "v":
value = JsonSerializer.Deserialize<LegacyPropValue>(ref reader, options);
break;
default:
reader.Skip();
break;
}
}
if (name is not null)
{
bag.Set(name, value ?? new LegacyPropValue.NullValue());
}
}
return bag;
}
public override void Write(Utf8JsonWriter writer, PropBag value, JsonSerializerOptions options)
{
writer.WriteStartArray();
foreach (var (name, propValue) in value)
{
writer.WriteStartObject();
writer.WriteString("n", name);
writer.WritePropertyName("v");
JsonSerializer.Serialize(writer, propValue, options);
writer.WriteEndObject();
}
writer.WriteEndArray();
}
}
/// <summary>LegacyPropValue 다형 JSON 컨버터 — "k" 판별자 기반</summary>
public sealed class LegacyPropValueJsonConverter : JsonConverter<LegacyPropValue>
{
public override LegacyPropValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return new LegacyPropValue.NullValue();
}
if (reader.TokenType == JsonTokenType.String)
{
// 축약형: 문자열이면 TextValue
return new LegacyPropValue.TextValue(reader.GetString() ?? string.Empty);
}
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException("LegacyPropValue 는 문자열 또는 객체여야 합니다.");
}
string? kind = null;
string? text = null;
string? base64 = null;
string? refName = null;
PropBag? nested = null;
List<LegacyItem>? items = null;
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
var propName = reader.GetString();
reader.Read();
switch (propName)
{
case "k": kind = reader.GetString(); break;
case "text": text = reader.GetString(); break;
case "b64": base64 = reader.GetString(); break;
case "ref": refName = reader.GetString(); break;
case "props": nested = JsonSerializer.Deserialize<PropBag>(ref reader, options); break;
case "items":
items = new List<LegacyItem>();
if (reader.TokenType != JsonTokenType.StartArray)
{
throw new JsonException("items 는 배열이어야 합니다.");
}
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
string? aqn = null;
LegacyPropValue? itemValue = null;
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
var itemProp = reader.GetString();
reader.Read();
switch (itemProp)
{
case "aqn": aqn = reader.GetString(); break;
case "v": itemValue = JsonSerializer.Deserialize<LegacyPropValue>(ref reader, options); break;
default: reader.Skip(); break;
}
}
items.Add(new LegacyItem { Aqn = aqn ?? string.Empty, Value = itemValue ?? new LegacyPropValue.NullValue() });
}
break;
default: reader.Skip(); break;
}
}
return kind switch
{
"text" => new LegacyPropValue.TextValue(text ?? string.Empty),
"null" => new LegacyPropValue.NullValue(),
"nested" => new LegacyPropValue.NestedValue { Children = nested ?? new PropBag() },
"items" => new LegacyPropValue.ItemsValue { Items = items ?? new List<LegacyItem>() },
"binary" => new LegacyPropValue.BinaryValue { Base64 = base64 ?? string.Empty },
"ref" => new LegacyPropValue.ReferenceValue { Name = refName ?? string.Empty },
_ => throw new JsonException($"알 수 없는 LegacyPropValue 종류: {kind}"),
};
}
public override void Write(Utf8JsonWriter writer, LegacyPropValue value, JsonSerializerOptions options)
{
switch (value)
{
case LegacyPropValue.TextValue text:
// 가장 흔한 형태는 축약(문자열)으로 — JSON 가독성
writer.WriteStringValue(text.Value);
break;
case LegacyPropValue.NullValue:
writer.WriteStartObject();
writer.WriteString("k", "null");
writer.WriteEndObject();
break;
case LegacyPropValue.NestedValue nested:
writer.WriteStartObject();
writer.WriteString("k", "nested");
writer.WritePropertyName("props");
JsonSerializer.Serialize(writer, nested.Children, options);
writer.WriteEndObject();
break;
case LegacyPropValue.ItemsValue items:
writer.WriteStartObject();
writer.WriteString("k", "items");
writer.WritePropertyName("items");
writer.WriteStartArray();
foreach (var item in items.Items)
{
writer.WriteStartObject();
writer.WriteString("aqn", item.Aqn);
writer.WritePropertyName("v");
JsonSerializer.Serialize(writer, item.Value, options);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
break;
case LegacyPropValue.BinaryValue binary:
writer.WriteStartObject();
writer.WriteString("k", "binary");
writer.WriteString("b64", binary.Base64);
writer.WriteEndObject();
break;
case LegacyPropValue.ReferenceValue reference:
writer.WriteStartObject();
writer.WriteString("k", "ref");
writer.WriteString("ref", reference.Name);
writer.WriteEndObject();
break;
}
}
}
@@ -0,0 +1,16 @@
using SheetMe.Core.Models;
namespace SheetMe.Core.Serialization;
/// <summary>
/// 서식 문서 직렬화 계약 — v1 주 포맷은 레거시 XML(<see cref="LegacyXmlSerializer"/>),
/// 부 포맷은 JSON(<see cref="FormJsonSerializer"/>). 추후 JSON 주 포맷 승격 시 교체 지점.
/// </summary>
public interface IDesignSerializer
{
/// <summary>문자열 콘텐츠를 서식 문서로 역직렬화</summary>
FormDocument Read(string content);
/// <summary>서식 문서를 문자열로 직렬화</summary>
string Write(FormDocument document);
}
@@ -0,0 +1,181 @@
using System.Globalization;
namespace SheetMe.Core.Serialization;
/// <summary>
/// 레거시 TypeConverter invariant 문자열 포맷 도우미.
/// 읽기는 관용적으로, 쓰기는 레거시 원문 형식("X, Y" / "이름, 11.25pt, style=Bold")과 동일하게.
/// </summary>
public static class LegacyFormat
{
#region Point / Size
/// <summary>"336, 1064" 형식 파싱 — 실패 시 (0,0)</summary>
public static (double X, double Y) ParsePoint(string? text)
{
if (TryParsePair(text, out var x, out var y))
{
return (x, y);
}
return (0, 0);
}
/// <summary>"174, 18" 형식 파싱 — 실패 시 (0,0)</summary>
public static (double W, double H) ParseSize(string? text) => ParsePoint(text);
/// <summary>Point/Size invariant 문자열 생성 — 레거시와 동일한 "X, Y" (정수 반올림)</summary>
public static string FormatPair(double a, double b)
=> string.Create(CultureInfo.InvariantCulture, $"{(int)Math.Round(a)}, {(int)Math.Round(b)}");
private static bool TryParsePair(string? text, out double a, out double b)
{
a = 0;
b = 0;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var parts = text.Split(',');
if (parts.Length != 2)
{
return false;
}
return double.TryParse(parts[0].Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out a)
&& double.TryParse(parts[1].Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out b);
}
#endregion
#region Font
/// <summary>
/// 레거시 FontConverter invariant 문자열 파싱 — "굴림, 11.25pt, style=Bold, Underline".
/// 실패 시 기본(굴림 9pt) 반환.
/// </summary>
public static LegacyFont ParseFont(string? text)
{
var font = new LegacyFont();
if (string.IsNullOrWhiteSpace(text))
{
return font;
}
var parts = text.Split(',');
if (parts.Length > 0)
{
font.Family = parts[0].Trim();
}
for (var i = 1; i < parts.Length; i++)
{
var part = parts[i].Trim();
if (part.EndsWith("pt", StringComparison.OrdinalIgnoreCase))
{
if (double.TryParse(part[..^2], NumberStyles.Number, CultureInfo.InvariantCulture, out var size))
{
font.SizePt = size;
}
}
else if (part.StartsWith("style=", StringComparison.OrdinalIgnoreCase))
{
ApplyStyle(font, part[6..]);
}
else
{
// "style=Bold, Italic" 처럼 콤마로 이어진 후속 스타일 토큰
ApplyStyle(font, part);
}
}
return font;
}
/// <summary>레거시 FontConverter invariant 형식으로 생성 — "이름, 11.25pt[, style=Bold, Italic]"</summary>
public static string FormatFont(LegacyFont font)
{
var size = font.SizePt.ToString("0.##", CultureInfo.InvariantCulture);
var styles = new List<string>(4);
if (font.Bold) { styles.Add("Bold"); }
if (font.Italic) { styles.Add("Italic"); }
if (font.Underline) { styles.Add("Underline"); }
if (font.Strikeout) { styles.Add("Strikeout"); }
return styles.Count == 0
? $"{font.Family}, {size}pt"
: $"{font.Family}, {size}pt, style={string.Join(", ", styles)}";
}
private static void ApplyStyle(LegacyFont font, string token)
{
switch (token.Trim())
{
case "Bold": font.Bold = true; break;
case "Italic": font.Italic = true; break;
case "Underline": font.Underline = true; break;
case "Strikeout": font.Strikeout = true; break;
}
}
#endregion
#region Color
/// <summary>
/// 레거시 ColorConverter invariant 문자열 파싱 — "224, 224, 224" / "255, 0, 0, 0"(ARGB) / "White" / "ControlDarkDark".
/// 반환: (A,R,G,B). 이름 색은 System.Drawing 으로 해석, 실패 시 검정.
/// </summary>
public static (byte A, byte R, byte G, byte B) ParseColor(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return (255, 0, 0, 0);
}
var parts = text.Split(',');
if (parts.Length is 3 or 4 && byte.TryParse(parts[0].Trim(), out _))
{
var values = new byte[parts.Length];
for (var i = 0; i < parts.Length; i++)
{
if (!byte.TryParse(parts[i].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out values[i]))
{
return (255, 0, 0, 0);
}
}
return parts.Length == 3
? ((byte)255, values[0], values[1], values[2])
: (values[0], values[1], values[2], values[3]);
}
// 명명색/시스템색 — System.Drawing 의 KnownColor 해석 사용(레거시와 동일 의미론)
var named = System.Drawing.Color.FromName(text.Trim());
if (named.IsKnownColor || named.A != 0 || named.R != 0 || named.G != 0 || named.B != 0)
{
return (named.A, named.R, named.G, named.B);
}
return (255, 0, 0, 0);
}
/// <summary>ColorConverter invariant 형식으로 생성 — 불투명 색은 "R, G, B", 반투명은 "A, R, G, B"</summary>
public static string FormatColor(byte a, byte r, byte g, byte b)
=> a == 255
? string.Create(CultureInfo.InvariantCulture, $"{r}, {g}, {b}")
: string.Create(CultureInfo.InvariantCulture, $"{a}, {r}, {g}, {b}");
#endregion
}
/// <summary>레거시 폰트 표현 — WinForms Font invariant 문자열과 1:1</summary>
public sealed class LegacyFont
{
/// <summary>글꼴 패밀리명</summary>
public string Family { get; set; } = "굴림";
/// <summary>크기(pt)</summary>
public double SizePt { get; set; } = 9;
/// <summary>굵게</summary>
public bool Bold { get; set; }
/// <summary>기울임</summary>
public bool Italic { get; set; }
/// <summary>밑줄</summary>
public bool Underline { get; set; }
/// <summary>취소선</summary>
public bool Strikeout { get; set; }
}
@@ -0,0 +1,102 @@
namespace SheetMe.Core.Serialization;
/// <summary>
/// 레거시 컨트롤 타입(AQN) ↔ 중립 타입명 매핑의 단일 출처.
/// 읽기: AQN 의 클래스 단축명을 정확 일치로 매핑(미지 타입은 "Placeholder").
/// 쓰기: 신규 생성 컨트롤의 AQN 을 실DB 샘플에서 확인된 원문 형식으로 생성.
/// </summary>
public static class LegacyTypeCatalog
{
#region Member Fields
/// <summary>미지원/미지 타입의 중립 타입명</summary>
public const string PlaceholderType = "Placeholder";
/// <summary>페이지 루트 클래스 단축명</summary>
public const string HostClassName = "MDesignerHost";
/// <summary>페이지 루트 AQN (실샘플 sheetdesign.xml 원문 확인)</summary>
public const string HostAqn =
"M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null";
// 레거시 클래스 단축명 → 중립 타입명 (정확 일치 — contains 순서 버그 원천 차단)
private static readonly Dictionary<string, string> legacyToNeutral = new(StringComparer.Ordinal)
{
["TextBox"] = "TextBox",
["Label"] = "Label",
["MFormatLabel"] = "Label",
["MSequence"] = "Label",
["CheckBox"] = "CheckBox",
["CheckLabel"] = "CheckBox",
["RadioButton"] = "RadioButton",
["ComboBox"] = "ComboBox",
["ListBox"] = "ListBox",
["MCheckedListBox"] = "CheckList",
["DateTimePicker"] = "DateTimePicker",
["MaskedTextBox"] = "MaskedTextBox",
["Panel"] = "Panel",
["Panel2"] = "Panel",
["MLayerPanel"] = "Panel",
["MExpandablePanel"] = "Panel",
["GroupBox"] = "GroupBox",
["MLine"] = "Line",
["MPictureBox"] = "PictureBox",
["MCalcBox"] = "CalcBox",
["MButton"] = "Button",
["MDataTable"] = "DataTable",
// Spread 는 읽기 매핑만(렌더) — E_SpdMst 디자인 없이는 신규 생성 불가하므로 쓰기 매핑/팔레트 미등록
["Spread"] = "Spread",
};
// 중립 타입명 → 신규 생성 시 사용할 레거시 클래스 단축명
private static readonly Dictionary<string, string> neutralToLegacyClass = new(StringComparer.Ordinal)
{
["TextBox"] = "TextBox",
["Label"] = "Label",
["CheckBox"] = "CheckBox",
["RadioButton"] = "RadioButton",
["ComboBox"] = "ComboBox",
["ListBox"] = "ListBox",
["CheckList"] = "MCheckedListBox",
["DateTimePicker"] = "DateTimePicker",
["MaskedTextBox"] = "MaskedTextBox",
["Panel"] = "Panel",
["GroupBox"] = "GroupBox",
["Line"] = "MLine",
["PictureBox"] = "MPictureBox",
["CalcBox"] = "MCalcBox",
["Button"] = "MButton",
["DataTable"] = "MDataTable",
};
#endregion
#region Methods
/// <summary>AQN 에서 클래스 단축명 추출 (예: "M.EMR.UserControl.TextBox, ..." → "TextBox")</summary>
public static string ShortClassName(string aqn)
{
var comma = aqn.IndexOf(',');
var fullName = comma >= 0 ? aqn[..comma] : aqn;
var dot = fullName.LastIndexOf('.');
return (dot >= 0 ? fullName[(dot + 1)..] : fullName).Trim();
}
/// <summary>AQN 을 중립 타입명으로 해석 — 미지 타입은 Placeholder</summary>
public static string Resolve(string aqn)
{
var shortName = ShortClassName(aqn);
return legacyToNeutral.TryGetValue(shortName, out var neutral) ? neutral : PlaceholderType;
}
/// <summary>페이지 루트(MDesignerHost) 타입인지 판정</summary>
public static bool IsHost(string aqn) => ShortClassName(aqn) == HostClassName;
/// <summary>중립 타입명의 신규 생성용 AQN — 미지원 타입이면 null</summary>
public static string? AqnOf(string neutralType)
{
if (!neutralToLegacyClass.TryGetValue(neutralType, out var legacyClass))
{
return null;
}
return $"M.EMR.UserControl.{legacyClass}, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null";
}
#endregion
}
@@ -0,0 +1,384 @@
using System.Text;
using System.Xml;
using SheetMe.Core.Models;
namespace SheetMe.Core.Serialization;
/// <summary>
/// 레거시 서식 XML 직렬화기 — v1 주 포맷.
/// 포맷 원본: [003]DesignerHosting\clsBasicDesignerLoader.vb (읽기 LoadObject/ReadProperty·쓰기 WriteObjectDesignInfo),
/// 병합 규약: clsCommonLib.Xml_MergeXmlDocument (Sheet 루트 1개에 페이지당 MDesignerHost Object N개).
/// 모든 Property 를 PropBag 으로 무손실 왕복하며, 쓰기 시 Bounds 를 Location/Size/LocationOnBase 로 동기화한다.
/// </summary>
public sealed class LegacyXmlSerializer : IDesignSerializer
{
#region Methods - Read
/// <summary>레거시 XML 을 서식 문서로 역직렬화 — 파싱 경고는 document.Meta.ReadWarnings 에 수집</summary>
public FormDocument Read(string content)
{
var document = new FormDocument();
if (string.IsNullOrWhiteSpace(content))
{
document.Pages.Add(CreateEmptyPage(1));
return document;
}
// 레거시 로더(clsBasicDesignerLoader)와 동일하게 PreserveWhitespace=false 로 파싱.
// CheckCharacters=false: 레거시 데이터에 제어문자(예: Spread 구분자 0x05)가 원문 포함된 사례가 있어
// 구식 XmlTextReader(무검사)와 동일하게 관용 처리한다.
var xml = LoadLenient(content);
var root = xml.DocumentElement
?? throw new InvalidDataException("서식 XML 에 루트 요소가 없습니다.");
foreach (XmlNode node in root.ChildNodes)
{
if (node.NodeType != XmlNodeType.Element || node.Name != "Object")
{
continue;
}
var pageRoot = ParseObject((XmlElement)node, document.Meta.ReadWarnings);
document.Pages.Add(new FormPage { Root = pageRoot });
}
if (document.Pages.Count == 0)
{
document.Meta.ReadWarnings.Add("페이지(Object) 노드를 찾지 못해 빈 페이지를 생성했습니다.");
document.Pages.Add(CreateEmptyPage(1));
}
return document;
}
/// <summary>빈 페이지 생성 — 레거시 기본 신규 서식(MDesignerHost 720×856)과 동일</summary>
public static FormPage CreateEmptyPage(int pageNumber)
{
var root = new ControlElement
{
Type = LegacyTypeCatalog.HostClassName,
Id = $"MDesignerHost{pageNumber}",
LegacyAqn = LegacyTypeCatalog.HostAqn,
Bounds = new LayoutRect { W = 720, H = 856 },
HasSize = true,
};
root.Props.SetText("BackColor", "White");
root.Props.SetText("Name", root.Id);
root.Props.SetText("Size", "720, 856");
return new FormPage { Root = root };
}
private ControlElement ParseObject(XmlElement node, List<string> warnings)
{
var aqn = node.GetAttribute("type");
var nameAttr = node.Attributes["name"];
var displaySequenceAttr = node.GetAttribute("DisplaySequence");
var element = new ControlElement
{
LegacyAqn = string.IsNullOrEmpty(aqn) ? null : aqn,
Type = LegacyTypeCatalog.IsHost(aqn) ? LegacyTypeCatalog.HostClassName : LegacyTypeCatalog.Resolve(aqn),
Id = nameAttr?.Value ?? string.Empty,
HasNameAttr = nameAttr is not null,
HasChildrenAttr = node.Attributes["children"] is not null,
DisplaySequence = int.TryParse(displaySequenceAttr, out var seq) ? seq : 0,
};
if (element.Type == LegacyTypeCatalog.PlaceholderType)
{
warnings.Add($"미지원 컨트롤 타입 보존: {element.Id} ({LegacyTypeCatalog.ShortClassName(aqn)})");
}
foreach (XmlNode child in node.ChildNodes)
{
if (child.NodeType != XmlNodeType.Element)
{
continue;
}
switch (child.Name)
{
case "Object":
element.Children.Add(ParseObject((XmlElement)child, warnings));
break;
case "Property":
var propName = ((XmlElement)child).GetAttribute("name");
if (propName.Length == 0)
{
warnings.Add($"이름 없는 Property 무시: {element.Id}");
break;
}
element.Props.Set(propName, ParseValue((XmlElement)child, warnings));
break;
default:
warnings.Add($"알 수 없는 노드 무시: <{child.Name}> in {element.Id}");
break;
}
}
LiftBounds(element);
return element;
}
private LegacyPropValue ParseValue(XmlElement propNode, List<string> warnings)
{
var elementChildren = propNode.ChildNodes.OfType<XmlElement>().ToList();
if (elementChildren.Count == 0)
{
var hasText = propNode.ChildNodes
.Cast<XmlNode>()
.Any(n => n.NodeType is XmlNodeType.Text or XmlNodeType.CDATA);
return hasText
? new LegacyPropValue.TextValue(propNode.InnerText)
: new LegacyPropValue.NullValue();
}
if (elementChildren.All(e => e.Name == "Property"))
{
var nested = new LegacyPropValue.NestedValue();
foreach (var child in elementChildren)
{
var childName = child.GetAttribute("name");
if (childName.Length == 0)
{
warnings.Add("이름 없는 중첩 Property 무시");
continue;
}
nested.Children.Set(childName, ParseValue(child, warnings));
}
return nested;
}
if (elementChildren.All(e => e.Name == "Item"))
{
var items = new LegacyPropValue.ItemsValue();
foreach (var child in elementChildren)
{
items.Items.Add(new LegacyItem
{
Aqn = child.GetAttribute("type"),
Value = ParseValue(child, warnings),
});
}
return items;
}
if (elementChildren.Count == 1 && elementChildren[0].Name == "Binary")
{
return new LegacyPropValue.BinaryValue { Base64 = elementChildren[0].InnerText };
}
if (elementChildren.Count == 1 && elementChildren[0].Name == "Reference")
{
return new LegacyPropValue.ReferenceValue { Name = elementChildren[0].GetAttribute("name") };
}
warnings.Add($"해석 불가 Property 형태(자식: {string.Join(",", elementChildren.Select(e => e.Name).Distinct())}) — 중첩으로 보존 시도");
var fallback = new LegacyPropValue.NestedValue();
foreach (var child in elementChildren.Where(e => e.Name == "Property"))
{
var childName = child.GetAttribute("name");
if (childName.Length > 0)
{
fallback.Children.Set(childName, ParseValue(child, warnings));
}
}
return fallback;
}
private static void LiftBounds(ControlElement element)
{
var location = element.Props.GetText("Location");
if (location is not null)
{
var (x, y) = LegacyFormat.ParsePoint(location);
element.Bounds.X = x;
element.Bounds.Y = y;
element.HasLocation = true;
}
var size = element.Props.GetText("Size");
if (size is not null)
{
var (w, h) = LegacyFormat.ParseSize(size);
element.Bounds.W = w;
element.Bounds.H = h;
element.HasSize = true;
}
}
#endregion
#region Methods - Write
/// <summary>서식 문서를 레거시 XML(병합형 — Sheet 루트에 페이지당 Object)로 직렬화</summary>
public string Write(FormDocument document)
{
var xml = new XmlDocument();
var sheet = xml.CreateElement("Sheet");
var nameAttr = xml.CreateAttribute("name");
nameAttr.Value = "SheetDesignInfo";
sheet.Attributes.Append(nameAttr);
xml.AppendChild(sheet);
foreach (var page in document.Pages)
{
sheet.AppendChild(WriteObject(xml, page.Root, 0, 0, isRoot: true));
}
var builder = new StringBuilder();
using (var writer = XmlWriter.Create(builder, new XmlWriterSettings
{
OmitXmlDeclaration = true,
Indent = false,
// Entitize: 텍스트 내 CR 을 &#xD; 로 보존(레거시 XmlDocument 출력과 동일) — 재파싱 정규화 손실 방지
NewLineHandling = NewLineHandling.Entitize,
// 레거시 데이터의 제어문자(0x05 등) verbatim 재방출 허용
CheckCharacters = false,
}))
{
xml.Save(writer);
}
return builder.ToString();
}
/// <summary>관용 파서 — 문자 검사 없이 XmlDocument 로드(레거시 XmlTextReader 동등)</summary>
internal static XmlDocument LoadLenient(string content)
{
var xml = new XmlDocument();
using var reader = XmlReader.Create(new StringReader(content), new XmlReaderSettings
{
CheckCharacters = false,
});
xml.Load(reader);
return xml;
}
private XmlElement WriteObject(XmlDocument xml, ControlElement element, double absX, double absY, bool isRoot)
{
SyncBoundsToProps(element, absX, absY, isRoot);
var node = xml.CreateElement("Object");
// 어트리뷰트 순서는 레거시 writer 와 동일: type → name → children → DisplaySequence
var aqn = element.LegacyAqn ?? LegacyTypeCatalog.AqnOf(element.Type)
?? throw new InvalidOperationException($"타입 '{element.Type}' 의 레거시 AQN 을 결정할 수 없습니다: {element.Id}");
AppendAttr(xml, node, "type", aqn);
if (element.HasNameAttr && element.Id.Length > 0)
{
AppendAttr(xml, node, "name", element.Id);
}
if (element.HasChildrenAttr)
{
AppendAttr(xml, node, "children", "Controls");
}
AppendAttr(xml, node, "DisplaySequence", element.DisplaySequence.ToString());
// 레거시 writer 규약: 자식 Object 먼저, Property 나중
foreach (var child in element.Children)
{
node.AppendChild(WriteObject(xml, child, absX + child.Bounds.X, absY + child.Bounds.Y, isRoot: false));
}
foreach (var (name, value) in element.Props)
{
node.AppendChild(WriteProperty(xml, name, value));
}
return node;
}
/// <summary>
/// Bounds → Props(Location/Size/LocationOnBase/Name) 동기화 — 레거시 writer 의 재계산 동작과 동일.
/// XML 에서 읽어온 컨트롤(LegacyAqn 보유)은 있던 키만 갱신(순수 보존),
/// 신규 생성 컨트롤(LegacyAqn 없음)에는 레거시 필수 속성(LocationOnBase/Name — 실샘플 117/117 보유)을 추가한다.
/// </summary>
private static void SyncBoundsToProps(ControlElement element, double absX, double absY, bool isRoot)
{
var isNew = element.LegacyAqn is null;
if (!isRoot)
{
if (element.HasLocation || element.Bounds.X != 0 || element.Bounds.Y != 0)
{
element.Props.SetText("Location", LegacyFormat.FormatPair(element.Bounds.X, element.Bounds.Y));
element.HasLocation = true;
}
}
if (element.HasSize || element.Bounds.W != 0 || element.Bounds.H != 0)
{
element.Props.SetText("Size", LegacyFormat.FormatPair(element.Bounds.W, element.Bounds.H));
element.HasSize = true;
}
// LocationOnBase 는 레거시 writer 가 저장 시마다 재계산(부모 절대좌표 누적)
if (element.Props.Contains("LocationOnBase") || (!isRoot && isNew))
{
element.Props.SetText("LocationOnBase", LegacyFormat.FormatPair(absX, absY));
}
// Name Property 는 name 어트리뷰트와 동일 값 유지(개명 동기화)
if (element.Id.Length > 0 && (element.Props.Contains("Name") || isNew))
{
element.Props.SetText("Name", element.Id);
}
}
private XmlElement WriteProperty(XmlDocument xml, string name, LegacyPropValue value)
{
var node = xml.CreateElement("Property");
AppendAttr(xml, node, "name", name);
WriteValueInto(xml, node, value);
return node;
}
private void WriteValueInto(XmlDocument xml, XmlElement node, LegacyPropValue value)
{
switch (value)
{
case LegacyPropValue.TextValue text:
node.InnerText = text.Value;
break;
case LegacyPropValue.NullValue:
break;
case LegacyPropValue.NestedValue nested:
foreach (var (childName, childValue) in nested.Children)
{
node.AppendChild(WriteProperty(xml, childName, childValue));
}
break;
case LegacyPropValue.ItemsValue items:
foreach (var item in items.Items)
{
var itemNode = xml.CreateElement("Item");
AppendAttr(xml, itemNode, "type", item.Aqn);
WriteValueInto(xml, itemNode, item.Value);
node.AppendChild(itemNode);
}
break;
case LegacyPropValue.BinaryValue binary:
var binaryNode = xml.CreateElement("Binary");
binaryNode.InnerText = binary.Base64;
node.AppendChild(binaryNode);
break;
case LegacyPropValue.ReferenceValue reference:
var referenceNode = xml.CreateElement("Reference");
AppendAttr(xml, referenceNode, "name", reference.Name);
node.AppendChild(referenceNode);
break;
}
}
private static void AppendAttr(XmlDocument xml, XmlElement node, string name, string value)
{
var attr = xml.CreateAttribute(name);
attr.Value = value;
node.Attributes.Append(attr);
}
#endregion
}
@@ -0,0 +1,111 @@
using System.Text;
using System.Xml;
namespace SheetMe.Core.Serialization;
/// <summary>
/// XML 의미론 비교 — 레거시 로더가 보는 의미가 같은지 검사.
/// 요소명·어트리뷰트·자식 요소 순서·텍스트를 비교하고, 빈 요소의 self-closing 여부와 요소 사이 공백은 무시한다.
/// (왕복 테스트와 실DB 일괄 스모크가 공유)
/// </summary>
public static class XmlSemanticDiff
{
#region Methods
/// <summary>두 XML 문자열의 의미 차이 목록 — 완전 동일하면 빈 목록 (제어문자 관용 파싱)</summary>
public static List<string> Compare(string expectedXml, string actualXml, int maxDiffs = 30)
{
var expected = LegacyXmlSerializer.LoadLenient(expectedXml);
var actual = LegacyXmlSerializer.LoadLenient(actualXml);
var diffs = new List<string>();
CompareElements(expected.DocumentElement!, actual.DocumentElement!, "/", diffs, maxDiffs);
return diffs;
}
private static void CompareElements(XmlElement expected, XmlElement actual, string path, List<string> diffs, int maxDiffs)
{
if (diffs.Count >= maxDiffs)
{
return;
}
var currentPath = $"{path}{expected.Name}[{Describe(expected)}]";
if (expected.Name != actual.Name)
{
diffs.Add($"{currentPath}: 요소명 다름 — 기대 <{expected.Name}> 실제 <{actual.Name}>");
return;
}
var expectedAttrs = expected.Attributes.Cast<XmlAttribute>().ToDictionary(a => a.Name, a => a.Value);
var actualAttrs = actual.Attributes.Cast<XmlAttribute>().ToDictionary(a => a.Name, a => a.Value);
foreach (var (name, value) in expectedAttrs)
{
if (!actualAttrs.TryGetValue(name, out var actualValue))
{
diffs.Add($"{currentPath}: 어트리뷰트 누락 — {name}=\"{value}\"");
}
else if (actualValue != value)
{
diffs.Add($"{currentPath}: 어트리뷰트 값 다름 — {name} 기대 \"{value}\" 실제 \"{actualValue}\"");
}
}
foreach (var name in actualAttrs.Keys.Where(k => !expectedAttrs.ContainsKey(k)))
{
diffs.Add($"{currentPath}: 어트리뷰트 추가됨 — {name}=\"{actualAttrs[name]}\"");
}
var expectedElements = expected.ChildNodes.OfType<XmlElement>().ToList();
var actualElements = actual.ChildNodes.OfType<XmlElement>().ToList();
if (expectedElements.Count != actualElements.Count)
{
diffs.Add($"{currentPath}: 자식 요소 수 다름 — 기대 {expectedElements.Count} 실제 {actualElements.Count} " +
$"(기대: {Summarize(expectedElements)} / 실제: {Summarize(actualElements)})");
}
var count = Math.Min(expectedElements.Count, actualElements.Count);
for (var i = 0; i < count; i++)
{
CompareElements(expectedElements[i], actualElements[i], currentPath + "/", diffs, maxDiffs);
if (diffs.Count >= maxDiffs)
{
return;
}
}
if (expectedElements.Count == 0 && actualElements.Count == 0)
{
var expectedText = GetMeaningfulText(expected);
var actualText = GetMeaningfulText(actual);
if (expectedText != actualText)
{
diffs.Add($"{currentPath}: 텍스트 다름 — 기대 \"{Truncate(expectedText)}\" 실제 \"{Truncate(actualText)}\"");
}
}
}
// 공백 전용 텍스트는 레거시 로더에서 null 로 취급 — "" 와 동일시
private static string GetMeaningfulText(XmlElement element)
{
var text = new StringBuilder();
foreach (XmlNode node in element.ChildNodes)
{
if (node.NodeType is XmlNodeType.Text or XmlNodeType.CDATA)
{
text.Append(node.Value);
}
}
return text.ToString();
}
private static string Describe(XmlElement element)
=> element.GetAttribute("name") is { Length: > 0 } name ? name : element.Name;
private static string Summarize(List<XmlElement> elements)
=> string.Join(",", elements.Take(8).Select(Describe)) + (elements.Count > 8 ? ",..." : string.Empty);
private static string Truncate(string value)
=> value.Length > 60 ? value[..60] + "..." : value;
#endregion
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>SheetMe.Core</RootNamespace>
<AssemblyName>SheetMe.Core</AssemblyName>
</PropertyGroup>
<ItemGroup>
<!-- 레거시 XML 속성값(Font/Color/Point 등) invariant 문자열 정합용 — 로컬 캐시 보유 버전 고정 -->
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
</ItemGroup>
</Project>
+28
View File
@@ -0,0 +1,28 @@
namespace SheetMe.Data.Config;
/// <summary>
/// 데이터 계층 설정 — appsettings.json 바인딩 모델.
/// SaveMode 기본값은 File — 운영 레거시 테이블(E_SdgMst/E_SctMst) 쓰기는 명시적 설정 후에만 활성화된다.
/// </summary>
public sealed class DataConfig
{
#region Properties
/// <summary>Oracle 접속 문자열 (ConnectionStrings:His)</summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>DB 프로바이더 — 기본 Oracle</summary>
public string Provider { get; set; } = "Oracle";
/// <summary>저장 모드 — File(기본, .xml 파일) | Db(E_SdgMst/E_SctMst 저장)</summary>
public string SaveMode { get; set; } = "File";
/// <summary>파일 모드 서식 폴더</summary>
public string XmlFolder { get; set; } = "forms";
#endregion
#region Methods
/// <summary>DB 저장 모드 여부</summary>
public bool IsDbSaveMode()
=> string.Equals(SaveMode, "Db", StringComparison.OrdinalIgnoreCase);
#endregion
}
@@ -0,0 +1,31 @@
using M.Framework.TableFramework;
using M.MW.Data.EMR;
namespace SheetMe.Data.DataContexts;
/// <summary>
/// EMR 서식 마스터 데이터 컨텍스트 — M.MW.Data.EMR 기존 엔티티 재사용(레거시 테이블과 1:1).
/// 목록성 LINQ 조회 기반. CLOB/채번/트랜잭션 저장은 raw ODP.NET 스토어가 담당([200]SheetMe 검증 분업).
/// </summary>
public sealed class EmrDataContext : AbstractDataContext
{
#region Properties
/// <summary>서식 마스터 (E_ShtMst)</summary>
public DataSet<E_ShtMst> SheetMasters { get; }
/// <summary>서식 디자인 버전 (E_SdgMst)</summary>
public DataSet<E_SdgMst> SheetDesigns { get; }
/// <summary>서식 컨트롤 마스터 (E_SctMst)</summary>
public DataSet<E_SctMst> SheetControls { get; }
#endregion
#region Constructors
public EmrDataContext(string provider, string connectionString) : base(provider, connectionString)
{
SheetMasters = new(access);
SheetDesigns = new(access);
SheetControls = new(access);
}
#endregion
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>SheetMe.Data</RootNamespace>
<AssemblyName>SheetMe.Data</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SheetMe.Core\SheetMe.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- [200]SheetMe에서 검증된 조합과 동일 버전 고정 (M.MW.Data.EMR: E_ShtMst/E_SdgMst/E_SctMst 엔티티) -->
<PackageReference Include="M.Framework.DBAccess" Version="9.3.15.1" />
<PackageReference Include="M.Framework.TableFramework" Version="6.0.13.2" />
<PackageReference Include="M.MW.Data.EMR" Version="3.0.0.3" />
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.26.100" />
</ItemGroup>
</Project>
@@ -0,0 +1,426 @@
using Oracle.ManagedDataAccess.Client;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
namespace SheetMe.Data.Stores;
/// <summary>서식 목록 1건(열기 대화상자)</summary>
public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign);
/// <summary>디자인 버전 1건</summary>
public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string UpdUid, bool Deleted);
/// <summary>
/// 레거시 테이블(E_ShtMst/E_SdgMst/E_SctMst) 기반 서식 저장소 — 레거시 디자이너와 동일 저장 의미론.
/// 저장 흐름(bzSaveSheetDesignNControlInfo 이식, 단일 트랜잭션):
/// ShtCneYon='Y'+활성행 존재 → 제자리 갱신(E_SctMst 는 신규 이름만 추가),
/// 그 외 → 기존 활성행 SdgDelYon='Y' 후 신규 행 INSERT + E_SctMst 전체 재생성.
/// 채번: 전용 시퀀스 NEXTVAL 우선, 부재 시 MAX+1 폴백(트랜잭션 내 — [200]SheetMe 검증 방식).
/// </summary>
public sealed class OracleLegacyFormStore
{
#region Member Fields
private readonly string connectionString;
private readonly LegacyXmlSerializer serializer = new();
#endregion
#region Constructors
public OracleLegacyFormStore(string connectionString)
{
this.connectionString = connectionString;
}
#endregion
#region Methods -
/// <summary>서식 목록 검색 — 공백제거 UPPER LIKE 토큰 AND 매칭, 스캔서식(ShtTyp='S') 제외</summary>
public List<SheetSummary> ListSheets(string? keyword, int max = 300)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
var where = "WHERE NVL(s.ShtTyp,' ') <> 'S'";
var tokens = (keyword ?? string.Empty)
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
for (var i = 0; i < tokens.Length; i++)
{
where += $" AND (UPPER(REPLACE(s.ShtKorNam,' ','')) LIKE :k{i} OR UPPER(s.ShtCod) LIKE :k{i})";
command.Parameters.Add(new OracleParameter($"k{i}", $"%{tokens[i].ToUpperInvariant()}%"));
}
command.CommandText =
"SELECT * FROM (" +
" SELECT s.ShtCod, s.ShtKorNam," +
" (SELECT COUNT(*) FROM E_SdgMst d WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') AS DesignCount" +
$" FROM E_ShtMst s {where} ORDER BY s.ShtCod" +
$") WHERE ROWNUM <= {max}";
var result = new List<SheetSummary>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(new SheetSummary(
reader.GetString(0),
reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
reader.GetDecimal(2) > 0));
}
return result;
}
/// <summary>활성(비삭제 최신) 디자인 로드 — 없으면 null. FormId/Title/SourceSdgKey 채움</summary>
public FormDocument? LoadActiveDesign(string shtCod)
{
using var connection = Open();
var sdgKey = ResolveActiveSdgKey(connection, null, shtCod);
if (sdgKey == 0)
{
return null;
}
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k";
command.Parameters.Add(new OracleParameter("k", sdgKey));
var xml = command.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(xml))
{
return null;
}
var document = serializer.Read(xml);
document.FormId = shtCod;
document.Title = QueryTitle(connection, shtCod) ?? shtCod;
document.Meta.SourceSdgKey = sdgKey;
return document;
}
/// <summary>활성 디자인 원문 XML — (SdgKey, XML), 없으면 null (일괄 왕복 스모크용)</summary>
public (decimal SdgKey, string Xml)? LoadActiveDesignRaw(string shtCod)
{
using var connection = Open();
var sdgKey = ResolveActiveSdgKey(connection, null, shtCod);
if (sdgKey == 0)
{
return null;
}
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k";
command.Parameters.Add(new OracleParameter("k", sdgKey));
return command.ExecuteScalar() is string xml && !string.IsNullOrWhiteSpace(xml)
? (sdgKey, xml)
: null;
}
/// <summary>디자인 버전의 E_SctMst 행 수(검증용)</summary>
public int CountSctRows(decimal sdgKey)
{
using var connection = Open();
return Convert.ToInt32(Scalar(connection, null,
"SELECT COUNT(*) FROM E_SctMst WHERE SctSdgKey = :k", new OracleParameter("k", sdgKey))!);
}
/// <summary>서식의 제자리 갱신 여부(ShtCneYon)</summary>
public string GetShtCneYon(string shtCod)
{
using var connection = Open();
return Scalar(connection, null,
"SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s",
new OracleParameter("s", shtCod)) as string ?? " ";
}
/// <summary>특정 버전(SdgKey) 디자인 로드 — 이력 열람/복원용. 없으면 null</summary>
public FormDocument? LoadDesignByKey(string shtCod, decimal sdgKey)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k";
command.Parameters.Add(new OracleParameter("k", sdgKey));
if (command.ExecuteScalar() is not string xml || string.IsNullOrWhiteSpace(xml))
{
return null;
}
var document = serializer.Read(xml);
document.FormId = shtCod;
document.Title = QueryTitle(connection, shtCod) ?? shtCod;
document.Meta.SourceSdgKey = sdgKey;
return document;
}
/// <summary>
/// 서식의 Spread 디자인들(E_SpdMst) — 컨트롤명(SpdName) → FarPoint XML.
/// SpdShtCod 는 CHAR 패딩 컬럼이라 TRIM 비교(실측).
/// </summary>
public Dictionary<string, string> LoadSpreadDesigns(string shtCod)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT TRIM(SpdName), SpdDesign FROM E_SpdMst " +
"WHERE TRIM(SpdShtCod) = :s AND NVL(SpdDelYon,' ') <> 'Y'";
command.Parameters.Add(new OracleParameter("s", shtCod));
using var reader = command.ExecuteReader();
while (reader.Read())
{
if (!reader.IsDBNull(1))
{
result[reader.GetString(0)] = reader.GetString(1);
}
}
return result;
}
/// <summary>디자인 버전 이력(본문 제외)</summary>
public List<DesignVersionInfo> ListVersions(string shtCod)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT SdgKey, NVL(SdgUpdDtm,' '), NVL(SdgUidCod,' '), NVL(SdgDelYon,' ') " +
"FROM E_SdgMst WHERE SdgShtCod = :s ORDER BY SdgKey DESC";
command.Parameters.Add(new OracleParameter("s", shtCod));
var result = new List<DesignVersionInfo>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(new DesignVersionInfo(
reader.GetDecimal(0), reader.GetString(1).Trim(), reader.GetString(2).Trim(),
reader.GetString(3) == "Y"));
}
return result;
}
#endregion
#region Methods -
/// <summary>
/// 디자인 저장 — 레거시 동일 의미론(제자리 갱신 vs 버저닝), 단일 트랜잭션.
/// 반환: 저장된 SdgKey.
/// </summary>
public decimal SaveDesign(FormDocument document, string uid)
{
if (string.IsNullOrWhiteSpace(document.FormId))
{
throw new InvalidOperationException("서식 코드(FormId)가 없습니다.");
}
var mergedXml = serializer.Write(document);
var now = DateTime.Now.ToString("yyyyMMddHHmm"); // 레거시 SystemDateTime 포맷(12자리)
using var connection = Open();
using var transaction = connection.BeginTransaction();
try
{
var shtCneYon = Scalar(connection, transaction,
"SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s",
new OracleParameter("s", document.FormId)) as string;
if (shtCneYon is null)
{
throw new InvalidOperationException($"E_ShtMst 에 서식이 없습니다: {document.FormId}");
}
var activeSdgKey = ResolveActiveSdgKey(connection, transaction, document.FormId);
var updateInPlace = shtCneYon == "Y" && activeSdgKey > 0;
decimal sdgKey;
if (updateInPlace)
{
// 제자리 갱신 — 레거시 ShtCneYon='Y' 경로
sdgKey = activeSdgKey;
NonQuery(connection, transaction,
"UPDATE E_SdgMst SET SdgDesign = :d, SdgUidCod = :u, SdgUpdDtm = :t WHERE SdgKey = :k",
new OracleParameter("d", OracleDbType.Clob) { Value = mergedXml },
new OracleParameter("u", uid),
new OracleParameter("t", now),
new OracleParameter("k", sdgKey));
}
else
{
// 기존 활성행 히스토리 처리 후 신규 행
if (activeSdgKey > 0)
{
NonQuery(connection, transaction,
"UPDATE E_SdgMst SET SdgDelYon = 'Y' WHERE SdgKey = :k",
new OracleParameter("k", activeSdgKey));
}
sdgKey = NextVal(connection, transaction, "E_SDGMST_SDGKEY",
"SELECT NVL(MAX(SdgKey),0)+1 FROM E_SdgMst");
NonQuery(connection, transaction,
"INSERT INTO E_SdgMst (SdgKey, SdgShtCod, SdgDesign, SdgUidCod, SdgUpdDtm, SdgDelYon, SdgDelUid, SdgDelDtm) " +
"VALUES (:k, :s, :d, :u, :t, '', '', '')",
new OracleParameter("k", sdgKey),
new OracleParameter("s", document.FormId),
new OracleParameter("d", OracleDbType.Clob) { Value = mergedXml },
new OracleParameter("u", uid),
new OracleParameter("t", now));
}
SaveSctMst(connection, transaction, sdgKey, mergedXml, updateInPlace);
transaction.Commit();
document.Meta.SourceSdgKey = sdgKey;
document.Meta.UpdatedBy = uid;
document.Meta.UpdatedAt = now;
return sdgKey;
}
catch
{
transaction.Rollback();
throw;
}
}
/// <summary>E_ShtMst 존재 여부</summary>
public bool SheetExists(string shtCod)
{
using var connection = Open();
return Convert.ToInt32(Scalar(connection, null,
"SELECT COUNT(*) FROM E_ShtMst WHERE ShtCod = :s", new OracleParameter("s", shtCod))!) > 0;
}
/// <summary>
/// 신규 서식 최소 등록 — 실DB 디자이너 서식 관행값(ShtTyp='D', 사용/디자인/컨트롤/EMR 'Y', 열람권한 'A' 등).
/// 상세 속성(권한/출력 등)은 레거시 기록지정보 마스터에서 후속 설정.
/// </summary>
public void RegisterSheet(string shtCod, string korName, string? clsCod, string uid)
{
if (shtCod.Length is 0 or > 10)
{
throw new ArgumentException("서식 코드는 1~10자여야 합니다.");
}
using var connection = Open();
using var transaction = connection.BeginTransaction();
try
{
NonQuery(connection, transaction,
"INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " +
"ShtUsrDesYon, ShtUseSdg, ShtUseSct, ShtUseEmr, ShtRedAth, ShtWrtTyp, ShtCneYon, ShtTraSto, " +
"ShtEleSig, ShtUpdUid, ShtUpdDtm) " +
"VALUES (:cod, :adp, :nam, :cls, 'D', 'Y', 'Y', 'Y', 'Y', 'Y', 'A', '9A', 'N', 'N', 'N', :uid, :dtm)",
new OracleParameter("cod", shtCod),
new OracleParameter("adp", DateTime.Now.ToString("yyyyMMdd")),
new OracleParameter("nam", korName),
new OracleParameter("cls", string.IsNullOrWhiteSpace(clsCod) ? (object)DBNull.Value : clsCod),
new OracleParameter("uid", uid),
new OracleParameter("dtm", DateTime.Now.ToString("yyyyMMddHHmm")));
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
private void SaveSctMst(OracleConnection connection, OracleTransaction transaction,
decimal sdgKey, string mergedXml, bool updateInPlace)
{
var rows = SctMstXmlWalker.Walk(mergedXml);
HashSet<string>? existingNames = null;
if (updateInPlace)
{
existingNames = new HashSet<string>(StringComparer.Ordinal);
using var query = connection.CreateCommand();
query.Transaction = transaction;
query.BindByName = true;
query.CommandText = "SELECT SctObjNam FROM E_SctMst WHERE SctSdgKey = :k";
query.Parameters.Add(new OracleParameter("k", sdgKey));
using var reader = query.ExecuteReader();
while (reader.Read())
{
existingNames.Add(reader.GetString(0));
}
}
foreach (var row in rows)
{
// 제자리 갱신: 기존 이름은 유지(레거시 — SctKey 보존), 신규 이름만 추가
if (existingNames is not null && existingNames.Contains(row.ObjName))
{
continue;
}
var sctKey = NextVal(connection, transaction, "E_SCTMST_SCTKEY",
"SELECT NVL(MAX(SctKey),0)+1 FROM E_SctMst");
NonQuery(connection, transaction,
"INSERT INTO E_SctMst (SctKey, SctSdgKey, SctObjNam, SctObjID, SctObjTyp, SctParObj, SctObjSeq, SctObjTxt) " +
"VALUES (:k, :sdg, :nam, :oid, :typ, :par, :seq, :txt)",
new OracleParameter("k", sctKey),
new OracleParameter("sdg", sdgKey),
new OracleParameter("nam", row.ObjName),
new OracleParameter("oid", row.ObjId),
new OracleParameter("typ", row.ObjType),
new OracleParameter("par", row.ParentId),
new OracleParameter("seq", row.ObjSeq.Length == 0 ? "0" : row.ObjSeq),
new OracleParameter("txt", row.ObjText.Length == 0 ? (object)DBNull.Value : row.ObjText));
}
}
#endregion
#region Methods - Private
private OracleConnection Open()
{
var connection = new OracleConnection(connectionString);
connection.Open();
return connection;
}
/// <summary>활성(비삭제 최신) SdgKey — 없으면 0 ([200] SdgKeyResolver 의미론)</summary>
private static decimal ResolveActiveSdgKey(OracleConnection connection, OracleTransaction? transaction, string shtCod)
{
var value = Scalar(connection, transaction,
"SELECT NVL((SELECT SdgKey FROM (SELECT SdgKey FROM E_SdgMst " +
"WHERE SdgShtCod = :s AND NVL(SdgDelYon,' ') <> 'Y' ORDER BY SdgUpdDtm DESC, SdgKey DESC) " +
"WHERE ROWNUM = 1), 0) FROM DUAL",
new OracleParameter("s", shtCod));
return value is null or DBNull ? 0m : Convert.ToDecimal(value);
}
private static string? QueryTitle(OracleConnection connection, string shtCod)
=> Scalar(connection, null,
"SELECT ShtKorNam FROM E_ShtMst WHERE ShtCod = :s", new OracleParameter("s", shtCod)) as string;
/// <summary>채번 — 전용 시퀀스 NEXTVAL 우선, 시퀀스 부재(ORA-02289/00942) 시 MAX+1 폴백</summary>
private static decimal NextVal(OracleConnection connection, OracleTransaction transaction, string sequence, string fallbackSql)
{
try
{
return Convert.ToDecimal(Scalar(connection, transaction, $"SELECT {sequence}.NEXTVAL FROM DUAL")!);
}
catch (OracleException ex) when (ex.Number is 2289 or 942)
{
return Convert.ToDecimal(Scalar(connection, transaction, fallbackSql)!);
}
}
private static object? Scalar(OracleConnection connection, OracleTransaction? transaction, string sql, params OracleParameter[] parameters)
{
using var command = connection.CreateCommand();
if (transaction is not null)
{
command.Transaction = transaction;
}
command.BindByName = true;
command.CommandText = sql;
command.Parameters.AddRange(parameters);
return command.ExecuteScalar();
}
private static void NonQuery(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.BindByName = true;
command.CommandText = sql;
command.Parameters.AddRange(parameters);
command.ExecuteNonQuery();
}
#endregion
}
+200
View File
@@ -0,0 +1,200 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>상용구 1건</summary>
public sealed record RecordWordInfo(decimal Key, string Value, decimal DisplaySeq);
/// <summary>
/// 서식별 상용구(E_SHTWRDMST) 저장소 — 1단계(SHTSTPLVL=1) 문구 CRUD.
/// 런타임 팝업(MRecordWord.dtRecWordMst) 조회 쿼리와 호환: SHTCOD+SHTSTPLVL=1, SHTDSPSEQ 정렬, 루트 SHTPARKEY=0.
/// 레거시에는 등록 UI 가 없어(INSERT 코드 부재 — DB 직접 관리) 본 디자이너가 관리 도구 역할을 겸한다.
/// </summary>
public sealed class RecordWordStore
{
#region Member Fields
private readonly string connectionString;
#endregion
#region Constructors
public RecordWordStore(string connectionString)
{
this.connectionString = connectionString;
}
#endregion
#region Methods
/// <summary>서식의 1단계 상용구 목록(표시순)</summary>
public List<RecordWordInfo> List(string shtCod)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT ShtWrdKey, ShtWrdVal, NVL(ShtDspSeq, 0) FROM E_SHTWRDMST " +
"WHERE ShtCod = :s AND ShtStpLvl = 1 ORDER BY ShtDspSeq, ShtWrdKey";
command.Parameters.Add(new OracleParameter("s", shtCod));
var result = new List<RecordWordInfo>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(new RecordWordInfo(reader.GetDecimal(0), reader.GetString(1), reader.GetDecimal(2)));
}
return result;
}
/// <summary>
/// 상용구 추가 — 키는 트리거 자동채번 우선(RETURNING 회수, 실측: 지정 키를 트리거가 덮어씀),
/// 트리거 부재(ORA-01400) 시 MAX+1 명시 폴백. 반환: 실제 저장된 키.
/// </summary>
public decimal Add(string shtCod, string value, string uid)
{
using var connection = Open();
using var transaction = connection.BeginTransaction();
try
{
var seq = Convert.ToDecimal(Scalar(connection, transaction,
"SELECT NVL(MAX(ShtDspSeq),0)+1 FROM E_SHTWRDMST WHERE ShtCod = :s AND ShtStpLvl = 1",
new OracleParameter("s", shtCod))!);
decimal key;
try
{
// 트리거 채번 경로 — 실제 키를 RETURNING 으로 회수
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.BindByName = true;
command.CommandText =
"INSERT INTO E_SHTWRDMST (ShtCod, ShtParKey, ShtWrdVal, ShtStpLvl, ShtDspSeq, ShtStt, " +
"ShtUpdUid, ShtUpdDtf, ShtFstUid, ShtFstDtf) " +
"VALUES (:s, 0, :v, 1, :q, 'U', :u, :d, :u, :d) " +
"RETURNING ShtWrdKey INTO :outKey";
command.Parameters.Add(new OracleParameter("s", shtCod));
command.Parameters.Add(new OracleParameter("v", value));
command.Parameters.Add(new OracleParameter("q", seq));
command.Parameters.Add(new OracleParameter("u", uid));
command.Parameters.Add(new OracleParameter("d", Now17()));
var outKey = new OracleParameter("outKey", OracleDbType.Decimal, System.Data.ParameterDirection.Output);
command.Parameters.Add(outKey);
command.ExecuteNonQuery();
key = ((Oracle.ManagedDataAccess.Types.OracleDecimal)outKey.Value!).Value;
}
catch (OracleException ex) when (ex.Number == 1400)
{
// 트리거 없음 — MAX+1 명시 채번
key = Convert.ToDecimal(Scalar(connection, transaction,
"SELECT NVL(MAX(ShtWrdKey),0)+1 FROM E_SHTWRDMST")!);
NonQuery(connection, transaction,
"INSERT INTO E_SHTWRDMST (ShtWrdKey, ShtCod, ShtParKey, ShtWrdVal, ShtStpLvl, ShtDspSeq, ShtStt, " +
"ShtUpdUid, ShtUpdDtf, ShtFstUid, ShtFstDtf) " +
"VALUES (:k, :s, 0, :v, 1, :q, 'U', :u, :d, :u, :d)",
new OracleParameter("k", key),
new OracleParameter("s", shtCod),
new OracleParameter("v", value),
new OracleParameter("q", seq),
new OracleParameter("u", uid),
new OracleParameter("d", Now17()));
}
transaction.Commit();
return key;
}
catch
{
transaction.Rollback();
throw;
}
}
/// <summary>상용구 문구 수정</summary>
public void Update(decimal key, string value, string uid)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"UPDATE E_SHTWRDMST SET ShtWrdVal = :v, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k";
command.Parameters.Add(new OracleParameter("v", value));
command.Parameters.Add(new OracleParameter("u", uid));
command.Parameters.Add(new OracleParameter("d", Now17()));
command.Parameters.Add(new OracleParameter("k", key));
command.ExecuteNonQuery();
}
/// <summary>상용구 삭제(하위 단계 포함)</summary>
public void Remove(decimal key)
{
using var connection = Open();
using var transaction = connection.BeginTransaction();
try
{
NonQuery(connection, transaction,
"DELETE FROM E_SHTWRDMST WHERE ShtParKey = :k", new OracleParameter("k", key));
NonQuery(connection, transaction,
"DELETE FROM E_SHTWRDMST WHERE ShtWrdKey = :k", new OracleParameter("k", key));
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
/// <summary>표시 순서 일괄 갱신 — 목록 순서대로 1..N</summary>
public void Reorder(IReadOnlyList<decimal> orderedKeys, string uid)
{
using var connection = Open();
using var transaction = connection.BeginTransaction();
try
{
for (var i = 0; i < orderedKeys.Count; i++)
{
NonQuery(connection, transaction,
"UPDATE E_SHTWRDMST SET ShtDspSeq = :q, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k",
new OracleParameter("q", (decimal)(i + 1)),
new OracleParameter("u", uid),
new OracleParameter("d", Now17()),
new OracleParameter("k", orderedKeys[i]));
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
private OracleConnection Open()
{
var connection = new OracleConnection(connectionString);
connection.Open();
return connection;
}
/// <summary>SHTUPDDTF 17자리(yyyyMMddHHmmssfff)</summary>
private static string Now17() => DateTime.Now.ToString("yyyyMMddHHmmssfff");
private static object? Scalar(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.BindByName = true;
command.CommandText = sql;
command.Parameters.AddRange(parameters);
return command.ExecuteScalar();
}
private static void NonQuery(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.BindByName = true;
command.CommandText = sql;
command.Parameters.AddRange(parameters);
command.ExecuteNonQuery();
}
#endregion
}
+150
View File
@@ -0,0 +1,150 @@
using System.Xml;
namespace SheetMe.Data.Stores;
/// <summary>E_SctMst 저장 행 1건 — XML 트리 순회 산출물</summary>
public sealed class SctRow
{
/// <summary>컨트롤 이름(SctObjNam)</summary>
public string ObjName { get; init; } = string.Empty;
/// <summary>계층 알파벳 ID(SctObjID) — 15자 우측 공백 패딩</summary>
public string ObjId { get; init; } = string.Empty;
/// <summary>타입 풀네임(SctObjTyp) — AQN 의 첫 콤마 앞</summary>
public string ObjType { get; init; } = string.Empty;
/// <summary>부모 알파벳 ID(SctParObj) — 15자 우측 공백 패딩</summary>
public string ParentId { get; init; } = string.Empty;
/// <summary>표시 순서(SctObjSeq) — DisplaySequence 어트리뷰트</summary>
public string ObjSeq { get; init; } = string.Empty;
/// <summary>텍스트(SctObjTxt) — Text Property 원문</summary>
public string ObjText { get; init; } = string.Empty;
}
/// <summary>
/// 서식 XML → E_SctMst 행 목록 변환 — 레거시 bzSaveSheetDesignNControlInfo.SaveE_SctMst 의 순수 이식.
/// 모델이 아닌 '최종 직렬화된 XML'을 순회해 100% 패리티(Spread AsTemplate 특례 포함)를 보장한다.
/// </summary>
public static class SctMstXmlWalker
{
#region Methods
/// <summary>병합 XML 의 전체 Object 트리를 E_SctMst 행 목록으로 (루트 MDesignerHost 포함 — 레거시 규약)</summary>
public static List<SctRow> Walk(string mergedXml)
{
var rows = new List<SctRow>();
var xml = new XmlDocument();
xml.LoadXml(mergedXml);
var objId = "A";
foreach (XmlNode node in xml.DocumentElement!.ChildNodes)
{
if (node.Name.Equals("Object", StringComparison.Ordinal))
{
AddRow(rows, (XmlElement)node, objId, string.Empty);
WalkChildren(rows, (XmlElement)node, objId);
}
// 레거시 동작 보존: 시퀀스는 노드 종류와 무관하게 진행(Sheet 자식은 Object 뿐이라 동일)
objId = NextAlphabetSequence(objId);
}
return rows;
}
/// <summary>알파벳 시퀀스 증가 — A→B…Z→AA→AB…, 전부 Z 면 자릿수 확장(clsCommonLib 이식)</summary>
public static string NextAlphabetSequence(string current)
{
var chars = current.ToCharArray();
var index = chars.Length - 1;
while (index >= 0 && chars[index] == 'Z')
{
chars[index] = 'A';
index--;
}
if (index < 0)
{
return "A" + new string(chars);
}
chars[index]++;
return new string(chars);
}
private static void WalkChildren(List<SctRow> rows, XmlElement parent, string parentObjId)
{
var childId = "AA";
foreach (XmlNode child in parent.ChildNodes)
{
if (!child.Name.Equals("Object", StringComparison.Ordinal))
{
continue;
}
AddRow(rows, (XmlElement)child, parentObjId + childId, parentObjId);
WalkChildren(rows, (XmlElement)child, parentObjId + childId);
childId = NextAlphabetSequence(childId);
}
}
private static void AddRow(List<SctRow> rows, XmlElement node, string objId, string parentId)
{
var name = node.GetAttribute("name");
var typeFullName = node.GetAttribute("type").Split(',')[0];
var displaySequence = node.GetAttribute("DisplaySequence");
var text = string.Empty;
string? spreadControlType = null;
string? spreadItemList = null;
foreach (XmlNode child in node.ChildNodes)
{
if (!child.Name.Equals("Property", StringComparison.OrdinalIgnoreCase) || child is not XmlElement prop)
{
continue;
}
var propName = prop.GetAttribute("name");
if (propName.Equals("Text", StringComparison.OrdinalIgnoreCase))
{
text = prop.InnerText;
}
else if (propName.Equals("ControlType", StringComparison.OrdinalIgnoreCase))
{
spreadControlType = prop.InnerText;
}
else if (propName.Equals("ControlTypeAsTemplate_ItemLIST", StringComparison.OrdinalIgnoreCase))
{
spreadItemList = prop.InnerText;
}
}
rows.Add(new SctRow
{
ObjName = name,
ObjId = objId.PadRight(15),
ObjType = typeFullName,
ParentId = parentId.PadRight(15),
ObjSeq = displaySequence,
ObjText = text,
});
// Spread AsTemplate 특례 — 항목 목록을 개별 행으로(레거시 이식, Chr(5) 구분)
if (string.Equals(typeFullName, "M.EMR.UserControl.Spread", StringComparison.OrdinalIgnoreCase)
&& string.Equals(spreadControlType, "AsTemplate", StringComparison.OrdinalIgnoreCase)
&& spreadItemList is not null)
{
var items = spreadItemList.Split((char)5);
for (var i = 0; i < items.Length - 1; i++)
{
rows.Add(new SctRow
{
ObjName = items[i],
ObjId = i.ToString(),
ObjType = "ItemOfSpreadAsTemplate",
ParentId = string.Empty,
ObjSeq = string.Empty,
ObjText = i.ToString().PadLeft(3, '0') + "_" + items[i],
});
}
}
}
#endregion
}
@@ -0,0 +1,35 @@
using System.Text;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
namespace SheetMe.Data.Stores;
/// <summary>
/// 파일 기반 서식 저장소 — 레거시 XML(.xml) 파일 열기/저장.
/// 레거시 디자이너의 XML 내보내기(TK_ExportXML) 산출물과 호환되며, DB 없는 개발 모드의 기본 저장소.
/// </summary>
public sealed class XmlFileFormStore
{
#region Member Fields
private readonly LegacyXmlSerializer serializer = new();
#endregion
#region Methods
/// <summary>XML 파일 열기 — FormId 는 파일명에서 유래</summary>
public FormDocument LoadFile(string path)
{
var content = File.ReadAllText(path);
var document = serializer.Read(content);
document.FormId = Path.GetFileNameWithoutExtension(path);
document.Title = document.FormId;
return document;
}
/// <summary>XML 파일 저장 — UTF-8(BOM 없음)</summary>
public void SaveFile(FormDocument document, string path)
{
var xml = serializer.Write(document);
File.WriteAllText(path, xml, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
#endregion
}
+16
View File
@@ -0,0 +1,16 @@
<Application x:Class="SheetMe.Designer.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- 타입별 렌더 템플릿 — 캔버스/미리보기/인쇄 공유 -->
<ResourceDictionary Source="/Controls/ControlTemplates.xaml"/>
<!-- [200]SheetMe 디자인 시스템 — 암시 스타일 + 키 스타일({DynamicResource B.*} 토큰 참조) -->
<ResourceDictionary Source="/Themes/DesignerTheme.xaml"/>
<!-- 색 토큰 — ThemeManager 가 Tokens.Dark/Light 를 Source 로 식별해 런타임 교체 -->
<ResourceDictionary Source="/Themes/Tokens.Dark.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+172
View File
@@ -0,0 +1,172 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using SheetMe.Designer.Services;
using SheetMe.Designer.Views;
namespace SheetMe.Designer;
/// <summary>
/// 서식생성기 애플리케이션 진입점.
/// 명령행: --render-smoke &lt;입력.xml&gt; &lt;출력.png&gt; — 첫 페이지를 오프스크린 렌더해 PNG 저장(검증용) 후 종료.
/// </summary>
public partial class App : Application
{
/// <summary>기동 — 스모크 모드 분기 또는 메인 셸 표시</summary>
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
if (e.Args.Length >= 3 && e.Args[0] == "--render-smoke")
{
var exitCode = RunRenderSmoke(e.Args[1], e.Args[2]);
Shutdown(exitCode);
return;
}
if (e.Args.Length >= 2 && e.Args[0] == "--edit-smoke")
{
Shutdown(Diagnostics.EditSmoke.Run(e.Args[1]));
return;
}
if (e.Args.Length >= 2 && e.Args[0] == "--db-smoke")
{
var max = e.Args.Length >= 3 && int.TryParse(e.Args[2], out var n) ? n : 30;
Shutdown(Diagnostics.DbSmoke.RunReadSmoke(e.Args[1], max));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-save-smoke")
{
Shutdown(Diagnostics.DbSmoke.RunSaveSmoke(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-find")
{
Shutdown(Diagnostics.DbSmoke.RunFind(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-columns")
{
Shutdown(Diagnostics.DbSmoke.RunColumns(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-row")
{
Shutdown(Diagnostics.DbSmoke.RunRow(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-sample")
{
Shutdown(Diagnostics.DbSmoke.RunSample(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-word-smoke")
{
Shutdown(Diagnostics.DbSmoke.RunWordSmoke(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-spd")
{
Shutdown(Diagnostics.DbSmoke.RunSpreadDump(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-xml")
{
Shutdown(Diagnostics.DbSmoke.RunDesignXml(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-render")
{
Shutdown(RunDbRenderSmoke(e.Args[1], e.Args[2]));
return;
}
Services.ThemeManager.LoadSaved();
new MainView().Show();
}
/// <summary>첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증)</summary>
private static int RunRenderSmoke(string xmlPath, string pngPath)
{
try
{
var store = new Data.Stores.XmlFileFormStore();
var document = store.LoadFile(xmlPath);
var page = DocumentMapper.CreatePage(document.Pages[0], 0);
var view = new PageView { DataContext = page };
var size = new Size(page.WidthDip, page.HeightDip);
view.Measure(size);
view.Arrange(new Rect(size));
view.UpdateLayout();
var bitmap = new RenderTargetBitmap(
(int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height), 96, 96, PixelFormats.Pbgra32);
bitmap.Render(view);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using var stream = File.Create(pngPath);
encoder.Save(stream);
Console.WriteLine($"렌더 스모크 완료: {pngPath} ({size.Width}x{size.Height}, " +
$"페이지 {document.Pages.Count}, 경고 {document.Meta.ReadWarnings.Count})");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"렌더 스모크 실패: {ex}");
return 1;
}
}
/// <summary>DB 서식 첫 페이지 오프스크린 렌더 → PNG (Spread 격자 주입 경로 포함 검증)</summary>
private static int RunDbRenderSmoke(string shtCod, string pngPath)
{
try
{
var dataBusiness = new DataBusiness.FormDesignDataBusiness();
var document = dataBusiness.OpenFromDb(shtCod);
if (document is null)
{
File.WriteAllText(pngPath + ".err.txt", $"활성 디자인 없음: {shtCod}");
return 1;
}
var designer = new ViewModels.DesignerViewModel(document, dataBusiness.LoadSpreadGrids(shtCod));
var page = designer.Pages[0];
var view = new PageView { DataContext = page };
var size = new Size(page.WidthDip, page.HeightDip);
view.Measure(size);
view.Arrange(new Rect(size));
view.UpdateLayout();
var bitmap = new RenderTargetBitmap(
(int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height), 96, 96, PixelFormats.Pbgra32);
bitmap.Render(view);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using var stream = File.Create(pngPath);
encoder.Save(stream);
return 0;
}
catch (Exception ex)
{
File.WriteAllText(pngPath + ".err.txt", ex.ToString());
return 1;
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,48 @@
using System.Windows;
using Microsoft.Xaml.Behaviors;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Behaviors;
/// <summary>캔버스 드롭 수신 — 팔레트 타입을 월드 좌표에 배치.</summary>
public sealed class CanvasDropBehavior : Behavior<FrameworkElement>
{
#region Methods
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.AllowDrop = true;
AssociatedObject.DragOver += OnDragOver;
AssociatedObject.Drop += OnDrop;
}
protected override void OnDetaching()
{
AssociatedObject.DragOver -= OnDragOver;
AssociatedObject.Drop -= OnDrop;
base.OnDetaching();
}
private void OnDragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(PaletteDragBehavior.DataFormat)
? DragDropEffects.Copy
: DragDropEffects.None;
e.Handled = true;
}
private void OnDrop(object sender, DragEventArgs e)
{
if (AssociatedObject.DataContext is not DesignerViewModel designer)
{
return;
}
if (e.Data.GetData(PaletteDragBehavior.DataFormat) is not string type)
{
return;
}
designer.AddControlAt(type, e.GetPosition(AssociatedObject));
e.Handled = true;
}
#endregion
}
@@ -0,0 +1,69 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using Microsoft.Xaml.Behaviors;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Behaviors;
/// <summary>
/// 캔버스 키보드 단축키 — 텍스트 입력 요소에 포커스가 있으면 통과(입력 보호).
/// 화살표 넛지(Shift=그리드), Del, Ctrl+A/C/V/D/Z/Y, Esc.
/// </summary>
public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
{
#region Methods
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.PreviewKeyDown += OnPreviewKeyDown;
}
protected override void OnDetaching()
{
AssociatedObject.PreviewKeyDown -= OnPreviewKeyDown;
base.OnDetaching();
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if ((AssociatedObject as FrameworkElement)?.DataContext is not DesignerViewModel designer)
{
return;
}
// 텍스트 입력 보호 — 포커스가 편집 컨트롤이면 단축키 라우팅 안 함
if (Keyboard.FocusedElement is TextBoxBase or ComboBox or PasswordBox)
{
return;
}
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
var step = shift ? designer.Snap.GridSize * 2 : 1;
var handled = true;
switch (e.Key)
{
case Key.Left: designer.NudgeSelection(-step, 0); break;
case Key.Right: designer.NudgeSelection(step, 0); break;
case Key.Up: designer.NudgeSelection(0, -step); break;
case Key.Down: designer.NudgeSelection(0, step); break;
case Key.Delete: designer.DeleteSelection(); break;
case Key.Escape: designer.Interaction.Cancel(); break;
case Key.A when ctrl: designer.SelectAllOnActivePage(); break;
case Key.C when ctrl: designer.CopySelection(); break;
case Key.X when ctrl: designer.CutSelection(); break;
case Key.V when ctrl: designer.Paste(); break;
case Key.D when ctrl: designer.CopySelection(); designer.Paste(); break;
case Key.Z when ctrl: designer.Undo.Undo(); break;
case Key.Y when ctrl: designer.Undo.Redo(); break;
case Key.G when ctrl && shift: designer.UngroupSelection(); break;
case Key.G when ctrl: designer.GroupSelection(); break;
default: handled = false; break;
}
e.Handled = handled;
}
#endregion
}
@@ -0,0 +1,110 @@
using System.Windows;
using System.Windows.Input;
using Microsoft.Xaml.Behaviors;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Behaviors;
/// <summary>
/// 캔버스 마우스 입력 → 월드 좌표 → InteractionController 위임.
/// World 요소(LayoutTransform 줌 하위)에 부착 — GetPosition(World)가 줌 불변 논리좌표를 반환.
/// 로직 없음: 좌표 변환·캡처·커서 매핑만 담당.
/// </summary>
public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
{
#region Methods
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.MouseLeftButtonDown += OnMouseDown;
AssociatedObject.MouseMove += OnMouseMove;
AssociatedObject.MouseLeftButtonUp += OnMouseUp;
AssociatedObject.LostMouseCapture += OnLostCapture;
}
protected override void OnDetaching()
{
AssociatedObject.MouseLeftButtonDown -= OnMouseDown;
AssociatedObject.MouseMove -= OnMouseMove;
AssociatedObject.MouseLeftButtonUp -= OnMouseUp;
AssociatedObject.LostMouseCapture -= OnLostCapture;
base.OnDetaching();
}
private DesignerViewModel? Designer => AssociatedObject.DataContext as DesignerViewModel;
private static PointerContext ContextOf(int clickCount = 1) => new(
Keyboard.Modifiers.HasFlag(ModifierKeys.Control),
Keyboard.Modifiers.HasFlag(ModifierKeys.Shift),
Keyboard.Modifiers.HasFlag(ModifierKeys.Alt),
clickCount);
private void OnMouseDown(object sender, MouseButtonEventArgs e)
{
if (Designer is not { } designer)
{
return;
}
// 키보드 포커스를 캔버스 컨테이너(Focusable 상위)로 — 단축키 수신
FocusCanvasHost();
designer.Interaction.PointerDown(e.GetPosition(AssociatedObject), ContextOf(e.ClickCount));
AssociatedObject.CaptureMouse();
e.Handled = true;
}
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (Designer is not { } designer)
{
return;
}
var world = e.GetPosition(AssociatedObject);
if (e.LeftButton == MouseButtonState.Pressed && AssociatedObject.IsMouseCaptured)
{
designer.Interaction.PointerMove(world, ContextOf());
}
AssociatedObject.Cursor = MapCursor(designer.Interaction.CursorAt(world));
}
private void OnMouseUp(object sender, MouseButtonEventArgs e)
{
if (Designer is not { } designer)
{
return;
}
designer.Interaction.PointerUp(e.GetPosition(AssociatedObject), ContextOf());
AssociatedObject.ReleaseMouseCapture();
e.Handled = true;
}
private void OnLostCapture(object sender, MouseEventArgs e)
=> Designer?.Interaction.CancelDrag(); // 정상 업 이후에는 no-op — 선택 유지
private void FocusCanvasHost()
{
// 상위 DesignerCanvasView(Focusable)로 포커스 — 키보드 단축키 수신
DependencyObject? current = AssociatedObject;
while (current is not null)
{
if (current is FrameworkElement { Focusable: true } focusable)
{
focusable.Focus();
return;
}
current = System.Windows.Media.VisualTreeHelper.GetParent(current);
}
}
private static Cursor MapCursor(CursorKind kind) => kind switch
{
CursorKind.SizeAll => Cursors.SizeAll,
CursorKind.SizeNWSE => Cursors.SizeNWSE,
CursorKind.SizeNESW => Cursors.SizeNESW,
CursorKind.SizeNS => Cursors.SizeNS,
CursorKind.SizeWE => Cursors.SizeWE,
_ => Cursors.Arrow,
};
#endregion
}
@@ -0,0 +1,76 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Microsoft.Xaml.Behaviors;
using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Behaviors;
/// <summary>팔레트 항목 드래그 시작 — 5px 임계 후 DoDragDrop("SheetMe.ControlType").</summary>
public sealed class PaletteDragBehavior : Behavior<ListBox>
{
#region Member Fields
/// <summary>드래그 데이터 포맷 키</summary>
public const string DataFormat = "SheetMe.ControlType";
private Point downPoint;
private ControlDescriptor? downItem;
#endregion
#region Methods
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.PreviewMouseLeftButtonDown += OnDown;
AssociatedObject.PreviewMouseMove += OnMove;
AssociatedObject.MouseDoubleClick += OnDoubleClick;
}
protected override void OnDetaching()
{
AssociatedObject.PreviewMouseLeftButtonDown -= OnDown;
AssociatedObject.PreviewMouseMove -= OnMove;
AssociatedObject.MouseDoubleClick -= OnDoubleClick;
base.OnDetaching();
}
/// <summary>더블클릭 즉시 배치 — 레거시 툴박스 UX(활성 페이지 중앙 부근)</summary>
private void OnDoubleClick(object sender, MouseButtonEventArgs e)
{
if ((e.OriginalSource as FrameworkElement)?.DataContext is not ControlDescriptor item)
{
return;
}
if (Application.Current.MainWindow?.DataContext is not ViewModels.MainViewModel main
|| main.CurrentDesigner is not { } designer)
{
return;
}
designer.AddPaletteItemAtCenter(item.Type);
e.Handled = true;
}
private void OnDown(object sender, MouseButtonEventArgs e)
{
downPoint = e.GetPosition(AssociatedObject);
downItem = (e.OriginalSource as FrameworkElement)?.DataContext as ControlDescriptor;
}
private void OnMove(object sender, MouseEventArgs e)
{
if (downItem is null || e.LeftButton != MouseButtonState.Pressed)
{
return;
}
var position = e.GetPosition(AssociatedObject);
if (Math.Abs(position.X - downPoint.X) < 5 && Math.Abs(position.Y - downPoint.Y) < 5)
{
return;
}
var item = downItem;
downItem = null;
DragDrop.DoDragDrop(AssociatedObject, new DataObject(DataFormat, item.Type), DragDropEffects.Copy);
}
#endregion
}
@@ -0,0 +1,375 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vmc="clr-namespace:SheetMe.Designer.ViewModels.Controls">
<!-- 타입별 렌더 템플릿 — 캔버스/미리보기/인쇄가 공유하는 단일 원천.
레거시 WinForms 렌더 모양의 시각 근사. 콘텐츠는 히트테스트 제외(입력은 월드 파이프라인 단일 수신). -->
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
<!-- 라벨 -->
<DataTemplate DataType="{x:Type vmc:LabelViewModel}">
<Grid Background="{Binding Background}" IsHitTestVisible="False">
<TextBlock Text="{Binding Text}"
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
FontWeight="{Binding FontWeight}" FontStyle="{Binding FontStyle}"
Foreground="{Binding Foreground}"
HorizontalAlignment="{Binding TextAlignment}"
VerticalAlignment="Top"
TextWrapping="Wrap"/>
</Grid>
</DataTemplate>
<!-- 텍스트박스 -->
<DataTemplate DataType="{x:Type vmc:TextBoxViewModel}">
<Border Background="{Binding Background}" IsHitTestVisible="False">
<Border.Style>
<Style TargetType="Border">
<Setter Property="BorderBrush" Value="#7F9DB9"/>
<Setter Property="BorderThickness" Value="1"/>
<Style.Triggers>
<DataTrigger Binding="{Binding ShowBorder}" Value="False">
<Setter Property="BorderThickness" Value="0"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding Text}" Margin="2,1"
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
FontWeight="{Binding FontWeight}" FontStyle="{Binding FontStyle}"
Foreground="{Binding Foreground}"
HorizontalAlignment="{Binding TextAlignment}"
VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="TextWrapping" Value="NoWrap"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Multiline}" Value="True">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="VerticalAlignment" Value="Top"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</DataTemplate>
<!-- 마스크 입력 -->
<DataTemplate DataType="{x:Type vmc:MaskedTextBoxViewModel}">
<Border Background="{Binding Background}" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
<TextBlock Margin="2,1" VerticalAlignment="Center"
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
Foreground="{Binding Foreground}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{Binding Text}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Text}" Value="">
<Setter Property="Text" Value="{Binding Mask}"/>
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</DataTemplate>
<!-- 체크박스 -->
<DataTemplate DataType="{x:Type vmc:CheckBoxViewModel}">
<StackPanel Orientation="Horizontal" Background="{Binding Background}" IsHitTestVisible="False">
<Border Width="13" Height="13" Background="White" BorderBrush="#5A5A5A" BorderThickness="1"
VerticalAlignment="Center" Margin="0,0,4,0">
<Path Data="M1,6 L5,10 L11,2" Stroke="#1E5AA0" StrokeThickness="2"
Visibility="{Binding IsChecked, Converter={StaticResource BoolToVisibility}}"/>
</Border>
<TextBlock Text="{Binding Text}" VerticalAlignment="Center"
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"/>
</StackPanel>
</DataTemplate>
<!-- 라디오버튼 -->
<DataTemplate DataType="{x:Type vmc:RadioButtonViewModel}">
<StackPanel Orientation="Horizontal" Background="{Binding Background}" IsHitTestVisible="False">
<Grid Width="13" Height="13" VerticalAlignment="Center" Margin="0,0,4,0">
<Ellipse Fill="White" Stroke="#5A5A5A" StrokeThickness="1"/>
<Ellipse Width="6" Height="6" Fill="#1E5AA0"
Visibility="{Binding IsChecked, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
<TextBlock Text="{Binding Text}" VerticalAlignment="Center"
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"/>
</StackPanel>
</DataTemplate>
<!-- 콤보박스 -->
<DataTemplate DataType="{x:Type vmc:ComboBoxViewModel}">
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
<DockPanel>
<Border DockPanel.Dock="Right" Width="17" Background="#E1E1E1">
<Path Data="M0,0 L8,0 L4,5 Z" Fill="#5A5A5A"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding DisplayText}" Margin="3,1" VerticalAlignment="Center"
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
Foreground="{Binding Foreground}" TextTrimming="CharacterEllipsis"/>
</DockPanel>
</Border>
</DataTemplate>
<!-- 리스트박스 -->
<DataTemplate DataType="{x:Type vmc:ListBoxViewModel}">
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
<ItemsControl ItemsSource="{Binding Items}" Margin="2">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="12"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</DataTemplate>
<!-- 체크리스트 -->
<DataTemplate DataType="{x:Type vmc:CheckListViewModel}">
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
<ItemsControl ItemsSource="{Binding Items}" Margin="2">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Border Width="11" Height="11" Background="White" BorderBrush="#5A5A5A"
BorderThickness="1" Margin="0,1,4,1"/>
<TextBlock Text="{Binding}" FontSize="12"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</DataTemplate>
<!-- 날짜선택 -->
<DataTemplate DataType="{x:Type vmc:DateTimePickerViewModel}">
<Border Background="White" BorderBrush="#7F9DB9" BorderThickness="1" IsHitTestVisible="False">
<DockPanel>
<Border DockPanel.Dock="Right" Width="17" Background="#E1E1E1">
<Path Data="M0,0 L8,0 L4,5 Z" Fill="#5A5A5A"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding DisplayText}" Margin="3,1" VerticalAlignment="Center"
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
Foreground="{Binding Foreground}" TextTrimming="CharacterEllipsis"/>
</DockPanel>
</Border>
</DataTemplate>
<!-- 패널 (컨테이너 — 자식 재귀 렌더) -->
<DataTemplate DataType="{x:Type vmc:PanelViewModel}">
<Grid Background="{Binding Background}" IsHitTestVisible="False">
<Border BorderBrush="#A0A0A0">
<Border.Style>
<Style TargetType="Border">
<Setter Property="BorderThickness" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding ShowBorder}" Value="True">
<Setter Property="BorderThickness" Value="1"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<ItemsControl ItemsSource="{Binding Children}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
<Setter Property="Width" Value="{Binding Width}"/>
<Setter Property="Height" Value="{Binding Height}"/>
</Style>
</ItemsControl.ItemContainerStyle>
</ItemsControl>
</Grid>
</DataTemplate>
<!-- 그룹박스 (컨테이너) -->
<DataTemplate DataType="{x:Type vmc:GroupBoxViewModel}">
<Grid Background="{Binding Background}" IsHitTestVisible="False">
<Border BorderBrush="#B5B5B5" BorderThickness="1" CornerRadius="3" Margin="0,7,0,0"/>
<TextBlock Text="{Binding Text}" Margin="8,0,0,0" Padding="3,0"
HorizontalAlignment="Left" VerticalAlignment="Top"
Background="{Binding Background}"
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"/>
<ItemsControl ItemsSource="{Binding Children}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
<Setter Property="Width" Value="{Binding Width}"/>
<Setter Property="Height" Value="{Binding Height}"/>
</Style>
</ItemsControl.ItemContainerStyle>
</ItemsControl>
</Grid>
</DataTemplate>
<!-- 선 -->
<DataTemplate DataType="{x:Type vmc:LineViewModel}">
<Rectangle Fill="{Binding LineBrush}" IsHitTestVisible="False"/>
</DataTemplate>
<!-- 이미지 -->
<DataTemplate DataType="{x:Type vmc:PictureBoxViewModel}">
<Border Background="#F5F7FA" BorderBrush="#C8D0DA" BorderThickness="1" IsHitTestVisible="False">
<TextBlock Text="{Binding Hint}" FontSize="10" Foreground="#8090A0"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Border>
</DataTemplate>
<!-- 계산박스 -->
<DataTemplate DataType="{x:Type vmc:CalcBoxViewModel}">
<Border Background="#FFFDF0" BorderBrush="#C9B458" BorderThickness="1" IsHitTestVisible="False">
<DockPanel>
<TextBlock DockPanel.Dock="Left" Text="ƒ" Margin="3,0,2,0" Foreground="#A08A2A"
FontWeight="Bold" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Text}" VerticalAlignment="Center"
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
Foreground="{Binding Foreground}"/>
</DockPanel>
</Border>
</DataTemplate>
<!-- 버튼 (레거시 MButton — 표준 버튼 근사) -->
<DataTemplate DataType="{x:Type vmc:ButtonViewModel}">
<Border CornerRadius="3" BorderBrush="#8A9AAB" BorderThickness="1" IsHitTestVisible="False">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#FDFDFD" Offset="0"/>
<GradientStop Color="#E8ECF0" Offset="0.5"/>
<GradientStop Color="#D8DEE6" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Grid>
<TextBlock Text="{Binding Text}"
FontFamily="{Binding FontFamily}" FontSize="{Binding FontSize}"
FontWeight="{Binding FontWeight}" Foreground="{Binding Foreground}"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding ActionHint}" FontSize="8" Foreground="#7A8AA0"
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,3,1"
TextTrimming="CharacterEllipsis"/>
</Grid>
</Border>
</DataTemplate>
<!-- 데이터소스 (레거시 MDataTable — 디자인 전용 배지, 런타임/인쇄 비가시) -->
<DataTemplate DataType="{x:Type vmc:DataTableViewModel}">
<Border Background="#EAF2FB" BorderBrush="#5B8DEF" BorderThickness="1" CornerRadius="4"
IsHitTestVisible="False" ToolTip="{Binding QuerySummary}">
<Grid>
<!-- DB 원통 아이콘 -->
<Path Fill="#5B8DEF" Stretch="Uniform" Margin="6"
Data="M4,3 C4,1.9 7.6,1 12,1 C16.4,1 20,1.9 20,3 L20,17 C20,18.1 16.4,19 12,19 C7.6,19 4,18.1 4,17 Z M4,3 C4,4.1 7.6,5 12,5 C16.4,5 20,4.1 20,3 M4,8 C4,9.1 7.6,10 12,10 C16.4,10 20,9.1 20,8 M4,13 C4,14.1 7.6,15 12,15 C16.4,15 20,14.1 20,13"/>
<TextBlock Text="{Binding Id}" FontSize="7" Foreground="#3E6BC4"
HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,1"
TextTrimming="CharacterEllipsis"/>
</Grid>
</Border>
</DataTemplate>
<!-- 표 (레거시 Spread — E_SpdMst 격자 렌더, 읽기 전용) -->
<DataTemplate DataType="{x:Type vmc:SpreadViewModel}">
<Border BorderBrush="#8A9AAB" BorderThickness="1" Background="White" IsHitTestVisible="False">
<Grid>
<!-- 격자선 -->
<ItemsControl ItemsSource="{Binding GridLines}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Line X1="{Binding X1}" Y1="{Binding Y1}" X2="{Binding X2}" Y2="{Binding Y2}"
Stroke="#C6CDD6" StrokeThickness="1"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- 셀 텍스트(스팬 반영) -->
<ItemsControl ItemsSource="{Binding CellViews}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Width="{Binding W}" Height="{Binding H}">
<TextBlock Text="{Binding Text}"
FontFamily="{Binding DataContext.FontFamily, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
FontSize="{Binding DataContext.FontSize, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" TextWrapping="Wrap"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- 격자 정보 없음(파일 열기 등) 안내 -->
<TextBlock Text="표 (Spread — DB에서 열면 격자 표시)" FontSize="10" Foreground="#8090A0"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasGrid}" Value="False">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</Border>
</DataTemplate>
<!-- 미지원 컨트롤 자리표시 (빗금 + 원본 타입명) -->
<DataTemplate DataType="{x:Type vmc:PlaceholderViewModel}">
<Border BorderBrush="#D08080" BorderThickness="1" IsHitTestVisible="False">
<Border.Background>
<DrawingBrush TileMode="Tile" Viewport="0,0,8,8" ViewportUnits="Absolute">
<DrawingBrush.Drawing>
<GeometryDrawing Geometry="M0,8 L8,0">
<GeometryDrawing.Pen>
<Pen Brush="#30D08080" Thickness="1"/>
</GeometryDrawing.Pen>
</GeometryDrawing>
</DrawingBrush.Drawing>
</DrawingBrush>
</Border.Background>
<TextBlock Text="{Binding LegacyClassName}" FontSize="10" Foreground="#B06060"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Border>
</DataTemplate>
</ResourceDictionary>
@@ -0,0 +1,183 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;
namespace SheetMe.Designer.Controls;
/// <summary>
/// Lucide(ISC) 아이콘을 WPF 로 렌더 — SVG(24x24, stroke 2, round) inner 마크업을
/// Geometry 로 파싱해 Viewbox+Path 로 그린다. 외부 패키지 불필요.
/// </summary>
public static class LucideIcons
{
private static readonly Dictionary<string, string> Data = new(StringComparer.Ordinal)
{
["file-text"] = """<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" />""",
["layout-dashboard"] = """<rect width="7" height="9" x="3" y="3" rx="1" /> <rect width="7" height="5" x="14" y="3" rx="1" /> <rect width="7" height="9" x="14" y="12" rx="1" /> <rect width="7" height="5" x="3" y="16" rx="1" />""",
["pencil-ruler"] = """<path d="M13 7 8.7 2.7a2.41 2.41 0 0 0-3.4 0L2.7 5.3a2.41 2.41 0 0 0 0 3.4L7 13" /> <path d="m8 6 2-2" /> <path d="m18 16 2-2" /> <path d="m17 11 4.3 4.3c.94.94.94 2.46 0 3.4l-2.6 2.6c-.94.94-2.46.94-3.4 0L11 17" /> <path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" />""",
["settings"] = """<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" />""",
["eye"] = """<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" /> <circle cx="12" cy="12" r="3" />""",
["save"] = """<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" /> <path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" /> <path d="M7 3v4a1 1 0 0 0 1 1h7" />""",
["folder-open"] = """<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />""",
["plus"] = """<path d="M5 12h14" /> <path d="M12 5v14" />""",
["trash-2"] = """<path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />""",
["link"] = """<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />""",
["sigma"] = """<path d="M18 7V5a1 1 0 0 0-1-1H6.5a.5.5 0 0 0-.4.8l4.5 6a2 2 0 0 1 0 2.4l-4.5 6a.5.5 0 0 0 .4.8H17a1 1 0 0 0 1-1v-2" />""",
["table"] = """<path d="M12 3v18" /> <rect width="18" height="18" x="3" y="3" rx="2" /> <path d="M3 9h18" /> <path d="M3 15h18" />""",
["printer"] = """<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" /> <path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6" /> <rect x="6" y="14" width="12" height="8" rx="1" />""",
["pen-line"] = """<path d="M13 21h8" /> <path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />""",
["building-2"] = """<path d="M10 12h4" /> <path d="M10 8h4" /> <path d="M14 21v-3a2 2 0 0 0-4 0v3" /> <path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2" /> <path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16" />""",
["package"] = """<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" /> <path d="M12 22V12" /> <polyline points="3.29 7 12 12 20.71 7" /> <path d="m7.5 4.27 9 5.15" />""",
["key-round"] = """<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" /> <circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />""",
["scroll-text"] = """<path d="M15 12h-5" /> <path d="M15 8h-5" /> <path d="M19 17V5a2 2 0 0 0-2-2H4" /> <path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" />""",
["search"] = """<path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" />""",
["circle-check"] = """<circle cx="12" cy="12" r="10" /> <path d="m9 12 2 2 4-4" />""",
["type"] = """<path d="M12 4v16" /> <path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" /> <path d="M9 20h6" />""",
["text-cursor-input"] = """<path d="M5 4h1a3 3 0 0 1 3 3 3 3 0 0 1 3-3h1" /> <path d="M13 20h-1a3 3 0 0 1-3-3 3 3 0 0 1-3 3H5" /> <path d="M5 16H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h1" /> <path d="M13 8h7a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-7" /> <path d="M9 7v10" />""",
["calendar"] = """<path d="M8 2v4" /> <path d="M16 2v4" /> <rect width="18" height="18" x="3" y="4" rx="2" /> <path d="M3 10h18" />""",
["list"] = """<path d="M3 5h.01" /> <path d="M3 12h.01" /> <path d="M3 19h.01" /> <path d="M8 5h13" /> <path d="M8 12h13" /> <path d="M8 19h13" />""",
["square-check"] = """<rect width="18" height="18" x="3" y="3" rx="2" /> <path d="m9 12 2 2 4-4" />""",
["circle-dot"] = """<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="1" />""",
["heading"] = """<path d="M6 12h12" /> <path d="M6 20V4" /> <path d="M18 20V4" />""",
["check"] = """<path d="M20 6 9 17l-5-5" />""",
["chevron-right"] = """<path d="m9 18 6-6-6-6" />""",
["chevron-left"] = """<path d="m15 18-6-6 6-6" />""",
["chevron-down"] = """<path d="m6 9 6 6 6-6" />""",
["layout-template"] = """<rect width="18" height="7" x="3" y="3" rx="1" /> <rect width="9" height="7" x="3" y="14" rx="1" /> <rect width="5" height="7" x="16" y="14" rx="1" />""",
["download"] = """<path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" />""",
["pencil"] = """<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" />""",
["folder"] = """<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />""",
["minus"] = """<path d="M5 12h14" />""",
["image"] = """<rect width="18" height="18" x="3" y="3" rx="2" ry="2" /> <circle cx="9" cy="9" r="2" /> <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />""",
["mouse-pointer-click"] = """<path d="M14 4.1 12 6" /> <path d="m5.1 8-2.9-.8" /> <path d="m6 12-1.9 2" /> <path d="M7.2 2.2 8 5.1" /> <path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z" />""",
["list-checks"] = """<path d="m3 17 2 2 4-4" /> <path d="m3 7 2 2 4-4" /> <path d="M13 6h8" /> <path d="M13 12h8" /> <path d="M13 18h8" />""",
["box"] = """<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /> <path d="m3.3 7 8.7 5 8.7-5" /> <path d="M12 22V12" />""",
["align-left"] = """<line x1="21" x2="3" y1="6" y2="6" /> <line x1="15" x2="3" y1="12" y2="12" /> <line x1="17" x2="3" y1="18" y2="18" />""",
["align-center"] = """<line x1="21" x2="3" y1="6" y2="6" /> <line x1="17" x2="7" y1="12" y2="12" /> <line x1="19" x2="5" y1="18" y2="18" />""",
["align-right"] = """<line x1="21" x2="3" y1="6" y2="6" /> <line x1="21" x2="9" y1="12" y2="12" /> <line x1="21" x2="7" y1="18" y2="18" />""",
["bold"] = """<path d="M14 12a4 4 0 0 0 0-8H6v8" /> <path d="M15 20a4 4 0 0 0 0-8H6v8Z" />""",
["italic"] = """<line x1="19" x2="10" y1="4" y2="4" /> <line x1="14" x2="5" y1="20" y2="20" /> <line x1="15" x2="9" y1="4" y2="20" />""",
["underline"] = """<path d="M6 4v6a6 6 0 0 0 12 0V4" /> <line x1="4" x2="20" y1="20" y2="20" />""",
["zoom-in"] = """<circle cx="11" cy="11" r="8" /> <line x1="21" x2="16.65" y1="21" y2="16.65" /> <line x1="11" x2="11" y1="8" y2="14" /> <line x1="8" x2="14" y1="11" y2="11" />""",
["zoom-out"] = """<circle cx="11" cy="11" r="8" /> <line x1="21" x2="16.65" y1="21" y2="16.65" /> <line x1="8" x2="14" y1="11" y2="11" />""",
["maximize"] = """<path d="M8 3H5a2 2 0 0 0-2 2v3" /> <path d="M21 8V5a2 2 0 0 0-2-2h-3" /> <path d="M3 16v3a2 2 0 0 0 2 2h3" /> <path d="M16 21h3a2 2 0 0 0 2-2v-3" />""",
["eye-off"] = """<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" /> <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" /> <path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" /> <path d="m2 2 20 20" />""",
["copy"] = """<rect width="14" height="14" x="8" y="8" rx="2" ry="2" /> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />""",
["clipboard"] = """<rect width="8" height="4" x="8" y="2" rx="1" ry="1" /> <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />""",
["undo-2"] = """<path d="M9 14 4 9l5-5" /> <path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11" />""",
["redo-2"] = """<path d="m15 14 5-5-5-5" /> <path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13" />""",
["sun"] = """<circle cx="12" cy="12" r="4" /> <path d="M12 2v2" /> <path d="M12 20v2" /> <path d="m4.93 4.93 1.41 1.41" /> <path d="m17.66 17.66 1.41 1.41" /> <path d="M2 12h2" /> <path d="M20 12h2" /> <path d="m6.34 17.66-1.41 1.41" /> <path d="m19.07 4.93-1.41 1.41" />""",
["moon"] = """<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />""",
["move-horizontal"] = """<polyline points="18 8 22 12 18 16" /> <polyline points="6 8 2 12 6 16" /> <line x1="2" x2="22" y1="12" y2="12" />""",
["move-vertical"] = """<polyline points="8 18 12 22 16 18" /> <polyline points="8 6 12 2 16 6" /> <line x1="12" x2="12" y1="2" y2="22" />""",
["align-obj-top"] = """<line x1="3" x2="21" y1="4" y2="4" /> <rect x="6" y="7" width="4" height="9" rx="1" /> <rect x="14" y="7" width="4" height="13" rx="1" />""",
["align-obj-middle"] = """<line x1="3" x2="21" y1="12" y2="12" /> <rect x="6" y="4" width="4" height="6" rx="1" /> <rect x="14" y="14" width="4" height="6" rx="1" />""",
["align-obj-bottom"] = """<line x1="3" x2="21" y1="20" y2="20" /> <rect x="6" y="8" width="4" height="9" rx="1" /> <rect x="14" y="4" width="4" height="13" rx="1" />""",
["distribute-h"] = """<rect x="3" y="7" width="3.5" height="10" rx="1" /> <rect x="10.25" y="7" width="3.5" height="10" rx="1" /> <rect x="17.5" y="7" width="3.5" height="10" rx="1" />""",
["distribute-v"] = """<rect x="7" y="3" width="10" height="3.5" rx="1" /> <rect x="7" y="10.25" width="10" height="3.5" rx="1" /> <rect x="7" y="17.5" width="10" height="3.5" rx="1" />""",
["square"] = """<rect width="18" height="18" x="3" y="3" rx="2" />""",
["lock"] = """<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />""",
["unlock"] = """<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 9.9-1" />""",
["group"] = """<path d="M3 7V5a2 2 0 0 1 2-2h2" /> <path d="M17 3h2a2 2 0 0 1 2 2v2" /> <path d="M21 17v2a2 2 0 0 1-2 2h-2" /> <path d="M7 21H5a2 2 0 0 1-2-2v-2" /> <rect width="7" height="5" x="7" y="7" rx="1" /> <rect width="7" height="5" x="10" y="12" rx="1" />""",
["ungroup"] = """<rect width="8" height="6" x="5" y="4" rx="1" /> <rect width="8" height="6" x="11" y="14" rx="1" />""",
["align-obj-left"] = """<line x1="4" x2="4" y1="3" y2="21" /> <rect x="7" y="6" width="9" height="4" rx="1" /> <rect x="7" y="14" width="13" height="4" rx="1" />""",
["align-obj-center-h"] = """<line x1="12" x2="12" y1="3" y2="21" /> <rect x="6" y="6" width="12" height="4" rx="1" /> <rect x="3" y="14" width="18" height="4" rx="1" />""",
["align-obj-right"] = """<line x1="20" x2="20" y1="3" y2="21" /> <rect x="11" y="6" width="9" height="4" rx="1" /> <rect x="7" y="14" width="13" height="4" rx="1" />""",
["droplet"] = """<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z" />""",
["rotate-cw"] = """<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" />""",
["frame"] = """<line x1="22" x2="2" y1="6" y2="6" /> <line x1="22" x2="2" y1="18" y2="18" /> <line x1="6" x2="6" y1="2" y2="22" /> <line x1="18" x2="18" y1="2" y2="22" />""",
["filter"] = """<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />""",
["filter-x"] = """<path d="M13.013 3H2l8 9.46V19l4 2v-8.54l.9-1.055" /> <path d="m22 3-5 5" /> <path d="m17 3 5 5" />""",
["user"] = """<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /> <circle cx="12" cy="7" r="4" />""",
["play"] = """<polygon points="6 3 20 12 6 21 6 3" />""",
["menu"] = """<line x1="4" x2="20" y1="12" y2="12" /> <line x1="4" x2="20" y1="6" y2="6" /> <line x1="4" x2="20" y1="18" y2="18" />""",
["hand"] = """<path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2" /> <path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2" /> <path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8" /> <path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" />""",
["layout-grid"] = """<rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" />""",
};
/// <summary>아이콘 + 텍스트 가로 묶음 (버튼/네비 Content 용).</summary>
public static StackPanel Label(string icon, string text, double iconSize = 15, Brush? color = null)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
sp.Children.Add(Icon(icon, iconSize, color));
if (!string.IsNullOrEmpty(text))
{
var tb = new TextBlock { Text = text, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
if (color is not null) tb.Foreground = color; // 아이콘과 같은 색을 글자에도 적용(다크 배경서 글자 안 묻히게)
sp.Children.Add(tb);
}
return sp;
}
public static FrameworkElement Icon(string name, double size = 16, Brush? color = null)
{
color ??= Brushes.Black;
var canvas = new Canvas { Width = 24, Height = 24 };
if (Data.TryGetValue(name, out var markup))
{
var (stroke, fill) = Parse(markup);
if (stroke.Children.Count > 0)
canvas.Children.Add(new Path { Data = stroke, Stroke = color, StrokeThickness = 2, StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round, StrokeLineJoin = PenLineJoin.Round });
if (fill.Children.Count > 0)
canvas.Children.Add(new Path { Data = fill, Fill = color });
}
return new Viewbox { Width = size, Height = size, Child = canvas, Stretch = Stretch.Uniform, SnapsToDevicePixels = true };
}
private static (GeometryGroup Stroke, GeometryGroup Fill) Parse(string markup)
{
var stroke = new GeometryGroup();
var fill = new GeometryGroup();
foreach (Match m in Regex.Matches(markup, @"<(path|rect|circle|line|polyline|polygon)\b([^>]*?)/?>", RegexOptions.Singleline))
{
var tag = m.Groups[1].Value;
var attrs = m.Groups[2].Value;
var isFill = string.Equals(Attr(attrs, "fill"), "currentColor", StringComparison.OrdinalIgnoreCase);
Geometry? g = tag switch
{
"path" => SafeParse(Attr(attrs, "d")),
"rect" => new RectangleGeometry(new Rect(D(attrs, "x"), D(attrs, "y"), D(attrs, "width"), D(attrs, "height")), D(attrs, "rx"), D(attrs, "rx")),
"circle" => new EllipseGeometry(new Point(D(attrs, "cx"), D(attrs, "cy")), D(attrs, "r"), D(attrs, "r")),
"line" => new LineGeometry(new Point(D(attrs, "x1"), D(attrs, "y1")), new Point(D(attrs, "x2"), D(attrs, "y2"))),
"polyline" => PolyGeo(Attr(attrs, "points"), false),
"polygon" => PolyGeo(Attr(attrs, "points"), true),
_ => null,
};
if (g is not null) (isFill ? fill : stroke).Children.Add(g);
}
return (stroke, fill);
}
private static string Attr(string attrs, string name)
{
var m = Regex.Match(attrs, name + @"\s*=\s*""([^""]*)""");
return m.Success ? m.Groups[1].Value : string.Empty;
}
private static double D(string attrs, string name)
=> double.TryParse(Attr(attrs, name), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0;
private static Geometry? SafeParse(string d)
{
if (string.IsNullOrWhiteSpace(d)) return null;
try { return Geometry.Parse(d); }
catch { return null; }
}
private static Geometry? PolyGeo(string points, bool closed)
{
var nums = Regex.Split(points.Trim(), @"[\s,]+").Where(s => s.Length > 0)
.Select(s => double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0).ToList();
if (nums.Count < 4) return null;
var sb = new StringBuilder();
for (int i = 0; i + 1 < nums.Count; i += 2)
sb.Append(i == 0 ? "M" : " L").Append(' ').Append(nums[i].ToString(CultureInfo.InvariantCulture)).Append(',').Append(nums[i + 1].ToString(CultureInfo.InvariantCulture));
if (closed) sb.Append(" Z");
return SafeParse(sb.ToString());
}
}
@@ -0,0 +1,102 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 팔레트 타입 → (Lucide 아이콘, 카테고리) 매핑 — [200]SheetMe 팔레트 타일 룩 이식.
/// Core(ControlRegistry)는 UI 무관하게 유지하고 표시 계층인 여기서만 매핑한다.
/// </summary>
public static class PaletteIconCatalog
{
#region Member Fields
private static readonly Dictionary<string, (string Icon, string Category)> Map = new(StringComparer.Ordinal)
{
["Label"] = ("type", "표시"),
["PictureBox"] = ("image", "표시"),
["Line"] = ("minus", "표시"),
["TextBox"] = ("text-cursor-input", "입력"),
["MaskedTextBox"] = ("text-cursor-input", "입력"),
["ComboBox"] = ("chevron-down", "입력"),
["ListBox"] = ("list", "입력"),
["CheckList"] = ("list-checks", "입력"),
["DateTimePicker"] = ("calendar", "입력"),
["CalcBox"] = ("sigma", "입력"),
["CheckBox"] = ("square-check", "선택"),
["RadioButton"] = ("circle-dot", "선택"),
["Panel"] = ("box", "컨테이너"),
["GroupBox"] = ("package", "컨테이너"),
["Button"] = ("mouse-pointer-click", "동작/데이터"),
["DataTable"] = ("table", "동작/데이터"),
};
#endregion
#region Methods
/// <summary>타입의 Lucide 아이콘명(미지정 타입은 square)</summary>
public static string IconOf(string type) => Map.TryGetValue(type, out var entry) ? entry.Icon : "square";
/// <summary>타입의 팔레트 카테고리(그룹 헤더)</summary>
public static string CategoryOf(string type) => Map.TryGetValue(type, out var entry) ? entry.Category : "기타";
#endregion
}
/// <summary>팔레트 타입명 → Lucide 아이콘 비주얼(FrameworkElement) 컨버터. parameter=크기(기본 18)</summary>
public sealed class TypeToIconConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string type)
{
return null;
}
var size = parameter is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 18;
var brush = System.Windows.Application.Current.TryFindResource("B.Muted") as Brush ?? Brushes.Gray;
return LucideIcons.Icon(PaletteIconCatalog.IconOf(type), size, brush);
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
/// <summary>팔레트 타입명 → 카테고리명 컨버터(CollectionViewSource 그룹핑용)</summary>
public sealed class TypeToCategoryConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is string type ? PaletteIconCatalog.CategoryOf(type) : "기타";
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
/// <summary>불리언 반전 컨버터(선택 도구 토글 = !IsHandTool 표시용)</summary>
public sealed class InverseBoolConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is not true;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is not true;
}
/// <summary>
/// Lucide 아이콘명 → 비주얼 컨버터(문서 탭 file-text, 플로팅 바 layout-grid 등 고정 아이콘용).
/// Binding Source 에 아이콘명 문자열을 넣어 사용 — 항목마다 새 비주얼이 생성되어 공유 부모 충돌이 없다.
/// parameter=크기(기본 14).
/// </summary>
public sealed class IconNameToVisualConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string name)
{
return null;
}
var size = parameter is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 14;
var brush = System.Windows.Application.Current.TryFindResource("B.Muted") as Brush ?? Brushes.Gray;
return LucideIcons.Icon(name, size, brush);
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
@@ -0,0 +1,117 @@
using SheetMe.Core.Models;
using SheetMe.Data.Config;
using SheetMe.Data.Stores;
using SheetMe.Designer.Services;
namespace SheetMe.Designer.DataBusiness;
/// <summary>
/// 서식 디자인 데이터 비즈니스 — 저장소(파일/DB) 접근을 ViewModel 에서 격리.
/// 본 도구는 내부 관리용 팻 클라이언트로 저장소를 직접 호출한다(컨벤션 편차 — 계획서 명시).
/// DB 저장은 SaveMode='Db' 설정 게이트 뒤에서만 활성화. REST 전환 시 이 계층만 교체한다.
/// </summary>
internal sealed class FormDesignDataBusiness
{
#region Member Fields
private readonly XmlFileFormStore fileStore = new();
private OracleLegacyFormStore? dbStore;
#endregion
#region Properties
/// <summary>데이터 설정(appsettings.json)</summary>
public DataConfig Config { get; } = ConfigLoader.Load();
/// <summary>DB 사용 가능 여부 — 접속 문자열 존재</summary>
public bool CanUseDb => Config.ConnectionString.Length > 0;
/// <summary>DB 저장 허용 여부 — SaveMode='Db' 명시 설정</summary>
public bool CanSaveToDb => CanUseDb && Config.IsDbSaveMode();
#endregion
#region Methods
/// <summary>레거시 XML 파일 열기</summary>
public FormDocument OpenXmlFile(string path) => fileStore.LoadFile(path);
/// <summary>레거시 XML 파일 저장</summary>
public void SaveXmlFile(FormDocument document, string path) => fileStore.SaveFile(document, path);
/// <summary>새 문서 생성 — 빈 페이지 1장(레거시 기본 720×856)</summary>
public FormDocument CreateNew()
{
var document = new FormDocument
{
FormId = "NewSheet",
Title = "새 서식",
};
document.Pages.Add(Core.Serialization.LegacyXmlSerializer.CreateEmptyPage(1));
return document;
}
/// <summary>DB 서식 목록 검색</summary>
public List<SheetSummary> ListSheets(string? keyword)
=> DbStore().ListSheets(keyword);
/// <summary>DB 활성 디자인 열기 — 디자인 없으면 null</summary>
public FormDocument? OpenFromDb(string shtCod)
=> DbStore().LoadActiveDesign(shtCod);
/// <summary>서식 디자인 버전 이력(E_SdgMst, 본문 제외)</summary>
public List<DesignVersionInfo> ListVersions(string shtCod)
=> DbStore().ListVersions(shtCod);
/// <summary>특정 버전 디자인 열기 — 이력 열람/복원</summary>
public FormDocument? OpenFromDbVersion(string shtCod, decimal sdgKey)
=> DbStore().LoadDesignByKey(shtCod, sdgKey);
/// <summary>서식의 Spread 격자(E_SpdMst 파싱) — 컨트롤명 → 격자 정보(파싱 실패 건 제외)</summary>
public Dictionary<string, Core.Serialization.SpreadGridInfo> LoadSpreadGrids(string shtCod)
{
var result = new Dictionary<string, Core.Serialization.SpreadGridInfo>(StringComparer.Ordinal);
foreach (var (name, xml) in DbStore().LoadSpreadDesigns(shtCod))
{
if (Core.Serialization.FarPointSpreadParser.Parse(xml) is { } grid)
{
result[name] = grid;
}
}
return result;
}
/// <summary>DB 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — 반환 SdgKey</summary>
public decimal SaveToDb(FormDocument document)
{
if (!CanSaveToDb)
{
throw new InvalidOperationException(
"DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요.");
}
return DbStore().SaveDesign(document, Environment.UserName);
}
/// <summary>서식 코드 등록 여부(E_ShtMst)</summary>
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
/// <summary>신규 서식 최소 등록 — 디자이너 서식 관행 기본값</summary>
public void RegisterSheet(string shtCod, string korName, string? clsCod)
=> DbStore().RegisterSheet(shtCod, korName, clsCod, Environment.UserName);
/// <summary>상용구 저장소(E_SHTWRDMST) — DB 접속 필요</summary>
public RecordWordStore RecordWords()
{
if (!CanUseDb)
{
throw new InvalidOperationException("DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.");
}
return new RecordWordStore(Config.ConnectionString);
}
private OracleLegacyFormStore DbStore()
{
if (!CanUseDb)
{
throw new InvalidOperationException("DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.");
}
return dbStore ??= new OracleLegacyFormStore(Config.ConnectionString);
}
#endregion
}
+393
View File
@@ -0,0 +1,393 @@
using System.IO;
using SheetMe.Core.Serialization;
using SheetMe.Data.Stores;
using SheetMe.Designer.Services;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 실DB 검증 진단.
/// --db-smoke &lt;리포트&gt; [N] : 읽기 전용 — 활성 디자인 N건 일괄 Read→Write 의미론 diff 통계.
/// --db-save-smoke &lt;서식코드&gt; &lt;리포트&gt; : 쓰기 — 무변경 재저장 후 재로드 비교 + E_SctMst 행 수 검증.
/// </summary>
public static class DbSmoke
{
#region Methods
/// <summary>읽기 전용 일괄 왕복 스모크</summary>
public static int RunReadSmoke(string reportPath, int maxSheets)
{
var lines = new List<string>();
try
{
var config = ConfigLoader.Load();
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000);
lines.Add($"E_ShtMst 서식 {sheets.Count}건, 디자인 보유 {sheets.Count(s => s.HasDesign)}건");
var targets = sheets.Where(s => s.HasDesign).Take(maxSheets).ToList();
var perfect = 0;
var withWarnings = 0;
var diffSheets = new List<string>();
var failed = new List<string>();
var placeholderTypes = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var sheet in targets)
{
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
var document = serializer.Read(raw.Value.Xml);
var rewritten = serializer.Write(document);
var diffs = XmlSemanticDiff.Compare(raw.Value.Xml, rewritten, maxDiffs: 5);
foreach (var warning in document.Meta.ReadWarnings.Where(w => w.StartsWith("미지원")))
{
var type = warning[(warning.IndexOf('(') + 1)..].TrimEnd(')');
placeholderTypes[type] = placeholderTypes.GetValueOrDefault(type) + 1;
}
if (diffs.Count == 0)
{
perfect++;
if (document.Meta.ReadWarnings.Count > 0)
{
withWarnings++;
}
}
else
{
diffSheets.Add($" {sheet.ShtCod} {sheet.Name}: {diffs[0]}");
}
}
catch (Exception ex)
{
failed.Add($" {sheet.ShtCod} {sheet.Name}: {ex.Message}");
}
}
lines.Add($"왕복 검사 {targets.Count}건 → 의미론 diff 0: {perfect}건 (미지원 컨트롤 보존 포함 {withWarnings}건), diff 발생: {diffSheets.Count}건, 예외: {failed.Count}건");
if (placeholderTypes.Count > 0)
{
lines.Add("미지원(자리표시 보존) 타입 분포: " + string.Join(", ",
placeholderTypes.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Key}×{kv.Value}")));
}
if (diffSheets.Count > 0)
{
lines.Add("[diff 발생 서식]");
lines.AddRange(diffSheets.Take(20));
}
if (failed.Count > 0)
{
lines.Add("[예외 서식]");
lines.AddRange(failed.Take(20));
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return diffSheets.Count == 0 && failed.Count == 0 ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
public static int RunColumns(string tableName, string reportPath)
{
try
{
var config = ConfigLoader.Load();
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT COLUMN_NAME, DATA_TYPE, NVL(DATA_LENGTH,0), NULLABLE " +
"FROM USER_TAB_COLUMNS WHERE TABLE_NAME = UPPER(:t) ORDER BY COLUMN_ID";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("t", tableName));
var lines = new List<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($"{reader.GetString(0),-16} {reader.GetString(1),-10} {reader.GetDecimal(2),5} " +
$"{(reader.GetString(3) == "N" ? "NOT NULL" : "")}");
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>서식 마스터 행 주요 플래그 조회(--db-row) — 신규 등록 기본값 참조용(읽기 전용)</summary>
public static int RunRow(string shtCod, string reportPath)
{
try
{
var config = ConfigLoader.Load();
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT NVL(ShtTyp,'-'), NVL(ShtUseYon,'-'), NVL(ShtUsrDesYon,'-'), NVL(ShtUseSdg,'-'), NVL(ShtUseSct,'-'), " +
"NVL(ShtUseEmr,'-'), NVL(ShtEleSig,'-'), NVL(ShtEdtAth,'-'), NVL(ShtRedAth,'-'), NVL(ShtWrtTyp,'-'), " +
"NVL(ShtEdtTyp,'-'), NVL(ShtViwTyp,'-'), NVL(ShtClsCod,'-'), NVL(ShtCneYon,'-'), NVL(ShtTraSto,'-') " +
"FROM E_ShtMst WHERE ShtCod = :s";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCod));
using var reader = command.ExecuteReader();
if (!reader.Read())
{
File.WriteAllText(reportPath, "행 없음");
return 1;
}
var names = new[] { "ShtTyp", "ShtUseYon", "ShtUsrDesYon", "ShtUseSdg", "ShtUseSct", "ShtUseEmr",
"ShtEleSig", "ShtEdtAth", "ShtRedAth", "ShtWrtTyp", "ShtEdtTyp", "ShtViwTyp", "ShtClsCod", "ShtCneYon", "ShtTraSto" };
var lines = names.Select((n, i) => $"{n}={reader.GetString(i)}");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용</summary>
public static int RunSample(string tableName, string reportPath, int max = 12)
{
try
{
if (!System.Text.RegularExpressions.Regex.IsMatch(tableName, "^[A-Za-z0-9_]{1,30}$"))
{
throw new ArgumentException("유효하지 않은 테이블명");
}
var config = ConfigLoader.Load();
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = $"SELECT * FROM {tableName} WHERE ROWNUM <= {max}";
var lines = new List<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var parts = new List<string>();
for (var i = 0; i < reader.FieldCount; i++)
{
var value = reader.IsDBNull(i) ? "∅" : reader.GetValue(i).ToString() ?? "";
if (value.Length > 30)
{
value = value[..30] + "…";
}
parts.Add($"{reader.GetName(i)}={value}");
}
lines.Add(string.Join(" | ", parts));
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>Spread 디자인 조회(--db-spd) — 목록 또는 특정 건 원문 덤프(읽기 전용)</summary>
public static int RunSpreadDump(string shtCodOrAll, string reportPath)
{
try
{
var config = ConfigLoader.Load();
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
if (shtCodOrAll == "*")
{
command.CommandText =
"SELECT SpdShtCod, SpdName, LENGTH(SpdDesign), NVL(SpdDelYon,' ') FROM E_SpdMst ORDER BY SpdShtCod";
var lines = new List<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
lines.Add($"{reader.GetString(0)}\t{reader.GetString(1)}\t{reader.GetDecimal(2):N0}자\t{(reader.GetString(3) == "Y" ? "" : "")}");
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
}
else
{
command.CommandText =
"SELECT SpdDesign FROM E_SpdMst WHERE TRIM(SpdShtCod) = :s AND NVL(SpdDelYon,' ') <> 'Y' AND ROWNUM = 1";
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCodOrAll));
File.WriteAllText(reportPath, command.ExecuteScalar() as string ?? "(없음)");
}
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>활성 디자인 XML 원문 덤프(--db-xml) — 읽기 전용</summary>
public static int RunDesignXml(string shtCod, string reportPath)
{
try
{
var config = ConfigLoader.Load();
var store = new OracleLegacyFormStore(config.ConnectionString);
var raw = store.LoadActiveDesignRaw(shtCod);
File.WriteAllText(reportPath,
raw is null ? "(없음)" : $"SdgKey={raw.Value.SdgKey}{Environment.NewLine}{raw.Value.Xml}");
return raw is null ? 1 : 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>서식 검색(--db-find) — 키워드 목록 출력</summary>
public static int RunFind(string keyword, string reportPath)
{
try
{
var config = ConfigLoader.Load();
var store = new OracleLegacyFormStore(config.ConnectionString);
var sheets = store.ListSheets(keyword, max: 50);
File.WriteAllText(reportPath, string.Join(Environment.NewLine,
sheets.Select(s => $"{s.ShtCod}\t{(s.HasDesign ? "O" : "-")}\t{s.Name}")));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>상용구(E_SHTWRDMST) CRUD 왕복 스모크(--db-word-smoke) — 등록→조회→수정→순서→삭제(흔적 없음)</summary>
public static int RunWordSmoke(string shtCod, string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigLoader.Load();
var store = new RecordWordStore(config.ConnectionString);
// 이전 스모크 잔여 정리
foreach (var stale in store.List(shtCod).Where(w => w.Value.StartsWith("스모크 문구")))
{
store.Remove(stale.Key);
}
var before = store.List(shtCod).Count;
lines.Add($"[사전] {shtCod} 상용구 {before}건");
var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", Environment.UserName);
var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", Environment.UserName);
var afterAdd = store.List(shtCod);
var okAdd = afterAdd.Count == before + 2
&& afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2);
lines.Add($"[추가] 2건 → {afterAdd.Count}건 (키 {key1}, {key2}) : {(okAdd ? "" : "")}");
store.Update(key1, "스모크 문구 1 (수정됨)", Environment.UserName);
var updatedValue = store.List(shtCod).FirstOrDefault(w => w.Key == key1)?.Value;
var okUpdate = updatedValue == "스모크 문구 1 (수정됨)";
lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "" : "")}");
var reordered = afterAdd.Select(w => w.Key).ToList();
reordered.Reverse();
store.Reorder(reordered, Environment.UserName);
var firstKey = store.List(shtCod).FirstOrDefault()?.Key;
var okReorder = firstKey == reordered[0];
lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "" : "")}");
store.Remove(key1);
store.Remove(key2);
var afterRemove = store.List(shtCod).Count;
var okRemove = afterRemove == before;
lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "" : "")}");
var ok = okAdd && okUpdate && okReorder && okRemove;
lines.Add(ok ? "결과: 통과" : "결과: 실패");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return ok ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>무변경 재저장 스모크 — 저장 경로 전체(트랜잭션/버저닝/E_SctMst) 검증</summary>
public static int RunSaveSmoke(string shtCod, string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigLoader.Load();
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var before = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException($"활성 디자인이 없습니다: {shtCod}");
var cneYon = store.GetShtCneYon(shtCod);
var versionsBefore = store.ListVersions(shtCod);
lines.Add($"[사전] {shtCod} ShtCneYon='{cneYon}' 활성 SdgKey={before.SdgKey}, 버전 {versionsBefore.Count}건, XML {before.Xml.Length:N0}자");
// 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신)
var document = serializer.Read(before.Xml);
document.FormId = shtCod;
var savedKey = store.SaveDesign(document, Environment.UserName);
var after = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패");
var versionsAfter = store.ListVersions(shtCod);
lines.Add($"[저장] SdgKey {before.SdgKey} → {savedKey} ({(cneYon == "Y" ? " " : " ")}), 버전 {versionsAfter.Count}건");
var diffs = XmlSemanticDiff.Compare(before.Xml, after.Xml, maxDiffs: 10);
lines.Add($"[비교] 원본 vs 재저장 활성본 의미론 diff: {diffs.Count}건");
lines.AddRange(diffs.Select(d => " " + d));
var sctCount = store.CountSctRows(savedKey);
var walkerCount = SctMstXmlWalker.Walk(after.Xml).Count;
lines.Add($"[E_SctMst] 저장본 행 {sctCount}건 / 워커 기대 {walkerCount}건 → {(sctCount == walkerCount ? "" : "")}");
if (cneYon != "Y")
{
var oldVersion = versionsAfter.FirstOrDefault(v => v.SdgKey == before.SdgKey);
lines.Add($"[이력] 이전 버전 SdgKey={before.SdgKey} SdgDelYon='Y' 처리: {(oldVersion?.Deleted == true ? "" : "!")}");
}
var ok = diffs.Count == 0 && sctCount == walkerCount;
lines.Add(ok ? "결과: 통과 — 레거시 디자이너/EMR 미리보기로 열어 최종 육안 확인 권장" : "결과: 실패");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return ok ? 0 : 1;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
#endregion
}
@@ -0,0 +1,408 @@
using System.IO;
using System.Windows;
using SheetMe.Core.Serialization;
using SheetMe.Designer.DataBusiness;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 편집 코어 자동 검증(--edit-smoke) — 실제 마우스 없이 InteractionController 에 포인터 시퀀스를 주입해
/// 선택/이동/리사이즈/마퀴/복사/붙여넣기/삭제/Undo/저장왕복을 검사한다.
/// </summary>
public static class EditSmoke
{
#region Methods
/// <summary>스모크 실행 — 결과를 리포트 파일에 기록, 실패 있으면 1 반환</summary>
public static int Run(string reportPath)
{
var lines = new List<string>();
var failed = 0;
void Check(string name, bool condition, string? detail = null)
{
if (!condition)
{
failed++;
}
lines.Add($"{(condition ? "PASS" : "FAIL")} {name}{(condition || detail is null ? "" : " " + detail)}");
}
try
{
var business = new FormDesignDataBusiness();
var serializer = new LegacyXmlSerializer();
var designer = new DesignerViewModel(business.CreateNew());
var none = new PointerContext(false, false, false, 1);
var alt = new PointerContext(false, false, true, 1);
var ctrl = new PointerContext(true, false, false, 1);
ControlViewModel Find(string type)
=> designer.Pages[0].Controls.First(c => c.Type == type);
// 1) 팔레트 배치
designer.AddControlAt("TextBox", new Point(100, 100));
designer.AddControlAt("Label", new Point(400, 60));
designer.AddControlAt("CheckBox", new Point(200, 260));
Check("배치: VM/모델 3개", designer.Pages[0].Controls.Count == 3
&& designer.Document.Pages[0].Controls.Count == 3);
Check("배치: 신규가 선택됨", designer.Selection.Items.Count == 1);
// 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖)
var textBox = Find("TextBox");
var clickPoint = new Point(textBox.X + textBox.Width / 2, textBox.Y + textBox.Height / 2);
designer.Interaction.PointerDown(clickPoint, none);
designer.Interaction.PointerUp(clickPoint, none);
Check("클릭 선택: Primary=TextBox", designer.Selection.Primary == textBox);
// 3) 드래그 이동(Alt=자유 이동 — 결정적 검증)
var origX = textBox.X;
var origY = textBox.Y;
designer.Interaction.PointerDown(clickPoint, none);
designer.Interaction.PointerMove(new Point(clickPoint.X + 33, clickPoint.Y + 47), alt);
designer.Interaction.PointerUp(new Point(clickPoint.X + 33, clickPoint.Y + 47), alt);
Check("드래그 이동 +33,+47", textBox.X == origX + 33 && textBox.Y == origY + 47,
$"실제 ({textBox.X},{textBox.Y}) 기대 ({origX + 33},{origY + 47})");
// 4) Undo → 위치 원복(VM 재생성되므로 재조회)
designer.Undo.Undo();
var textBoxAfterUndo = Find("TextBox");
Check("Undo: 위치 원복", textBoxAfterUndo.X == origX && textBoxAfterUndo.Y == origY,
$"실제 ({textBoxAfterUndo.X},{textBoxAfterUndo.Y})");
// 5) Redo → 이동 재적용
designer.Undo.Redo();
var textBoxAfterRedo = Find("TextBox");
Check("Redo: 이동 재적용", textBoxAfterRedo.X == origX + 33 && textBoxAfterRedo.Y == origY + 47);
// 6) 리사이즈 — SE 핸들(4번) 드래그
var target = Find("TextBox");
designer.Selection.SetSingle(target);
var bbox = designer.SelectionWorldBounds()!.Value;
var seHandle = new Point(bbox.Right, bbox.Bottom);
var origW = target.Width;
var origH = target.Height;
designer.Interaction.PointerDown(seHandle, none);
designer.Interaction.PointerMove(new Point(seHandle.X + 40, seHandle.Y + 18), alt);
designer.Interaction.PointerUp(new Point(seHandle.X + 40, seHandle.Y + 18), alt);
Check("리사이즈 +40,+18", target.Width == origW + 40 && target.Height == origH + 18,
$"실제 ({target.Width},{target.Height}) 기대 ({origW + 40},{origH + 18})");
// 7) 마퀴 다중선택 — 전체를 덮는 사각형
designer.Selection.Clear();
designer.Interaction.PointerDown(new Point(1, 1), none);
designer.Interaction.PointerMove(new Point(700, 700), none);
designer.Interaction.PointerUp(new Point(700, 700), none);
Check("마퀴: 3개 선택", designer.Selection.Items.Count == 3,
$"실제 {designer.Selection.Items.Count}");
// 8) Ctrl+클릭 토글 해제
var label = Find("Label");
var labelPoint = new Point(label.X + 3, label.Y + 3);
designer.Interaction.PointerDown(labelPoint, ctrl);
designer.Interaction.PointerUp(labelPoint, ctrl);
Check("Ctrl+클릭: 토글 해제 → 2개", designer.Selection.Items.Count == 2);
// 9) 복사/붙여넣기
designer.Selection.SetSingle(Find("CheckBox"));
designer.CopySelection();
designer.Paste();
Check("붙여넣기: 4개 + 새 Id", designer.Pages[0].Controls.Count == 4
&& designer.Pages[0].Controls.Count(c => c.Type == "CheckBox") == 2
&& designer.Pages[0].Controls.Where(c => c.Type == "CheckBox").Select(c => c.Id).Distinct().Count() == 2);
// 10) 삭제
designer.DeleteSelection();
Check("삭제: 3개", designer.Pages[0].Controls.Count == 3);
// 11) z-order — TextBox 맨 뒤로 (모델 마지막 = VM 첫번째)
designer.Selection.SetSingle(Find("TextBox"));
designer.ReorderSelection(toFront: false);
Check("맨 뒤로: 모델 끝/VM 처음",
designer.Document.Pages[0].Controls[^1].Type == "TextBox"
&& designer.Pages[0].Controls[0].Type == "TextBox");
// 12) 넛지
var nudgeTarget = Find("Label");
designer.Selection.SetSingle(nudgeTarget);
var beforeNudgeX = nudgeTarget.X;
designer.NudgeSelection(1, 0);
designer.NudgeSelection(1, 0);
Check("넛지 +2", nudgeTarget.X == beforeNudgeX + 2);
// 13) 저장 → 재로드 → 재직렬화 동일(왕복 안정)
var tempPath = Path.Combine(Path.GetTempPath(), "sheetme_editsmoke.xml");
business.SaveXmlFile(designer.Document, tempPath);
var reloaded = business.OpenXmlFile(tempPath);
var xmlA = serializer.Write(designer.Document);
var xmlB = serializer.Write(reloaded);
Check("저장/재로드 왕복 동일", xmlA == xmlB);
// 14) 신규 컨트롤 XML 에 레거시 필수 요소 포함
Check("XML: AQN/Location/Name 포함",
xmlA.Contains("M.EMR.UserControl.TextBox,") && xmlA.Contains("<Property name=\"Location\">")
&& xmlA.Contains("<Property name=\"Name\">"));
// 15) 인스펙터 — 선택 시 행 구성 + Text 커밋
var inspectorTarget = Find("Label");
designer.Selection.SetSingle(inspectorTarget);
Check("인스펙터: 행 생성", designer.Inspector.Rows.Count > 5,
$"행 {designer.Inspector.Rows.Count}");
var textRow = designer.Inspector.Rows
.OfType<ViewModels.Inspector.MultilineTextRowViewModel>()
.FirstOrDefault(r => r.Label == "텍스트");
Check("인스펙터: 텍스트 행 존재", textRow is not null);
if (textRow is not null)
{
textRow.ValueText = "수정된 라벨";
Check("인스펙터: Text 커밋 → 모델 반영",
inspectorTarget.Model.Props.GetText("Text") == "수정된 라벨"
&& inspectorTarget.Text == "수정된 라벨");
Check("인스펙터: 커밋이 Undo 스텝", designer.Undo.CanUndo);
}
// 16) 인스펙터 — 이름 변경
var nameRow = designer.Inspector.Rows
.OfType<ViewModels.Inspector.TextRowViewModel>()
.FirstOrDefault(r => r.Label == "이름");
if (nameRow is not null)
{
nameRow.ValueText = "lblTitle";
Check("인스펙터: 개명 반영", inspectorTarget.Id == "lblTitle");
}
// 17) 페이지 추가/삭제 + 용지 크기
designer.AddPage();
Check("페이지 추가: 2페이지", designer.Pages.Count == 2
&& designer.Document.Pages.Count == 2);
designer.SelectedPage = designer.Pages[1];
designer.ApplyPaperSize(794, 1123);
Check("용지 크기 변경(A4)", designer.Pages[1].WidthDip == 794 && designer.Pages[1].HeightDip == 1123);
designer.RemoveSelectedPageForSmoke();
Check("페이지 삭제: 1페이지", designer.Pages.Count == 1);
// 18) 컨테이너 드롭 — Panel 배치 후 내부에 TextBox 드롭
designer.AddControlAt("Panel", new Point(400, 400));
var panel = designer.Pages[0].Controls.OfType<PanelViewModel>().First();
designer.AddControlAt("TextBox", new Point(panel.X + 30, panel.Y + 30));
Check("컨테이너 드롭: 자식 추가", panel.Model.Children.Count == 1
&& panel.Children.Count == 1
&& panel.Model.Children[0].Type == "TextBox");
// 19) 컨테이너 자식 직접 편집 — 히트테스트/이동/삭제
var panelVm = designer.Pages[0].Controls.OfType<PanelViewModel>().First();
var child = panelVm.Children[0];
var childWorld = designer.WorldBoundsOf(child);
var childHit = designer.HitTestControl(new Point(childWorld.X + childWorld.Width / 2, childWorld.Y + childWorld.Height / 2));
Check("자식 히트테스트", childHit == child, $"실제 {childHit?.Id}");
designer.Selection.SetSingle(child);
var childOrigX = child.X;
var center = new Point(childWorld.X + childWorld.Width / 2, childWorld.Y + childWorld.Height / 2);
designer.Interaction.PointerDown(center, none);
designer.Interaction.PointerMove(new Point(center.X + 15, center.Y + 10), alt);
designer.Interaction.PointerUp(new Point(center.X + 15, center.Y + 10), alt);
Check("자식 이동(+15,+10 상대좌표)", child.X == childOrigX + 15,
$"실제 X={child.X} 기대 {childOrigX + 15}");
designer.Selection.SetSingle(child);
designer.DeleteSelection();
Check("자식 삭제", panelVm.Children.Count == 0 && panelVm.Model.Children.Count == 0);
// 20) 그룹/해제 — 그룹 단위 클릭 선택
designer.Selection.Set(new[] { Find("TextBox"), Find("Label") });
designer.GroupSelection();
designer.Selection.Clear();
var member = Find("TextBox");
var memberPoint = new Point(member.X + member.Width / 2, member.Y + member.Height / 2);
designer.Interaction.PointerDown(memberPoint, none);
designer.Interaction.PointerUp(memberPoint, none);
Check("그룹 클릭 → 그룹 전체 선택", designer.Selection.Items.Count == 2,
$"실제 {designer.Selection.Items.Count}");
designer.UngroupSelection();
designer.Selection.Clear();
designer.Interaction.PointerDown(memberPoint, none);
designer.Interaction.PointerUp(memberPoint, none);
Check("그룹 해제 후 단일 선택", designer.Selection.Items.Count == 1);
// 20-1) 인라인 텍스트 편집 — 더블클릭 요청 이벤트 + 커밋
var inlineTarget = Find("Label");
ControlViewModel? inlineRequested = null;
designer.InlineEditRequested += vm => inlineRequested = vm;
var inlineWorld = designer.WorldBoundsOf(inlineTarget);
var inlineCenter = new Point(inlineWorld.X + inlineWorld.Width / 2, inlineWorld.Y + inlineWorld.Height / 2);
designer.Interaction.PointerDown(inlineCenter, new PointerContext(false, false, false, 2));
designer.Interaction.PointerUp(inlineCenter, new PointerContext(false, false, false, 2));
Check("더블클릭 → 인라인 편집 요청", inlineRequested == inlineTarget,
$"실제 {inlineRequested?.Id ?? "null"}");
designer.CommitInlineText(inlineTarget, "인라인 수정됨");
Check("인라인 커밋 → 모델/Undo 반영",
inlineTarget.Text == "인라인 수정됨"
&& inlineTarget.Model.Props.GetText("Text") == "인라인 수정됨"
&& designer.Undo.CanUndo);
// 20-2) 폰트 일괄 변경 — 집계 + 적용
var usage = designer.CollectFontUsage();
Check("폰트 집계: 조합 존재", usage.Count >= 2, $"실제 {usage.Count}");
var applied = designer.ApplyFontBulk(usage, new Core.Serialization.LegacyFont
{
Family = "돋움",
SizePt = 12,
Bold = true,
});
Check("폰트 일괄 적용: 대상 수", applied >= 2, $"실제 {applied}");
var fontTarget = Find("Label");
Check("폰트 일괄 적용: 모델 반영",
fontTarget.Model.Props.GetText("Font") == "돋움, 12pt, style=Bold"
&& fontTarget.EffectiveFont.Family == "돋움" && fontTarget.EffectiveFont.Bold);
designer.Undo.Undo();
Check("폰트 일괄 적용: Undo 1스텝 원복",
Find("Label").Model.Props.GetText("Font") != "돋움, 12pt, style=Bold");
// 20-3) 문서 간 복사/붙여넣기 — A 서식에서 복사 → B 서식에 붙여넣기(클립보드 공유)
var designerB = new DesignerViewModel(business.CreateNew());
designer.Selection.SetSingle(Find("TextBox"));
designer.CopySelection();
var beforePasteB = designerB.Pages[0].Controls.Count;
designerB.Paste();
Check("문서 간 붙여넣기: B 문서에 추가", designerB.Pages[0].Controls.Count == beforePasteB + 1
&& designerB.Pages[0].Controls.Any(c => c.Type == "TextBox"),
$"실제 {designerB.Pages[0].Controls.Count}");
Check("문서 간 붙여넣기: A 문서 불변", designer.Pages[0].Controls.Count(c => c.Type == "TextBox") == 1);
// 20-4) 배치 명령 — 같은 크기/간격 균등/페이지 가운데/잘라내기
designer.AddControlAt("Label", new Point(100, 500));
designer.AddControlAt("Label", new Point(300, 520));
designer.AddControlAt("Label", new Point(560, 540));
var arrangeTargets = designer.Pages[0].Controls.Where(c => c.Y >= 480).ToList();
designer.Selection.Set(arrangeTargets, arrangeTargets[0]);
designer.SizeToControl("Both");
Check("같은 크기로: 기준에 통일",
arrangeTargets.All(c => c.Width == arrangeTargets[0].Width && c.Height == arrangeTargets[0].Height));
designer.AdjustSpacing("HorizEqual");
var sorted = arrangeTargets.OrderBy(c => c.X).ToList();
var gap1 = sorted[1].X - (sorted[0].X + sorted[0].Width);
var gap2 = sorted[2].X - (sorted[1].X + sorted[1].Width);
Check("가로 간격 균등", Math.Abs(gap1 - gap2) <= 1, $"gap1={gap1} gap2={gap2}");
designer.AdjustSpacing("HorizConcat");
sorted = arrangeTargets.OrderBy(c => c.X).ToList();
Check("가로 붙이기: 간격 0", sorted[1].X == sorted[0].X + sorted[0].Width
&& sorted[2].X == sorted[1].X + sorted[1].Width);
designer.CenterInPage("H");
var bboxAfterCenter = designer.SelectionWorldBounds()!.Value;
var pageW = designer.Pages[0].WidthDip;
Check("페이지 가로 가운데", Math.Abs((bboxAfterCenter.X + bboxAfterCenter.Width / 2) - pageW / 2) <= 1,
$"중심 {bboxAfterCenter.X + bboxAfterCenter.Width / 2} 기대 {pageW / 2}");
var beforeCut = designer.Pages[0].Controls.Count;
designer.Selection.Set(new[] { arrangeTargets[0] }, arrangeTargets[0]);
designer.CutSelection();
Check("잘라내기: 삭제됨", designer.Pages[0].Controls.Count == beforeCut - 1);
designer.Paste();
Check("잘라내기 → 붙여넣기 복원", designer.Pages[0].Controls.Count == beforeCut);
// 남은 배치 테스트 컨트롤 정리
designer.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 480).ToList());
designer.DeleteSelection();
// 20-5) 탭순서 편집 — 모드 진입 → 역순 클릭 → 완료 적용(레거시 규약 0,100,200…)
designer.AddControlAt("TextBox", new Point(100, 620));
designer.AddControlAt("TextBox", new Point(300, 620));
designer.AddControlAt("TextBox", new Point(500, 620));
var tabTargets = designer.Pages[0].Controls
.Where(c => c.Type == "TextBox" && c.Y >= 600)
.OrderBy(c => c.X)
.ToList();
designer.ToggleTabOrderMode(); // 시작
Check("탭순서: 모드 진입 + 배지 생성", designer.IsTabOrderMode
&& designer.Overlay.TabBadges.Count >= 3,
$"배지 {designer.Overlay.TabBadges.Count}");
// 오른쪽→왼쪽 역순으로 클릭(입력 순서 지정)
for (var i = tabTargets.Count - 1; i >= 0; i--)
{
var world = designer.WorldBoundsOf(tabTargets[i]);
designer.Interaction.PointerDown(new Point(world.X + world.Width / 2, world.Y + world.Height / 2), none);
designer.Interaction.PointerUp(new Point(world.X + world.Width / 2, world.Y + world.Height / 2), none);
}
Check("탭순서: 배지 순번 표시", designer.Overlay.TabBadges.Count(b => b.IsAssigned) == 3);
designer.ToggleTabOrderMode(); // 완료(적용)
Check("탭순서: 적용 — 클릭 순서 0/100/200", !designer.IsTabOrderMode
&& tabTargets[2].Model.Props.GetText("TabIndex") == "0"
&& tabTargets[1].Model.Props.GetText("TabIndex") == "100"
&& tabTargets[0].Model.Props.GetText("TabIndex") == "200",
$"실제 {tabTargets[2].Model.Props.GetText("TabIndex")}/{tabTargets[1].Model.Props.GetText("TabIndex")}/{tabTargets[0].Model.Props.GetText("TabIndex")}");
Check("탭순서: 미클릭 컨트롤 뒤 순번(999999+)",
Find("Label").Model.Props.GetText("TabIndex")?.StartsWith("1000") == true);
designer.Undo.Undo();
Check("탭순서: Undo 원복",
designer.Pages[0].Controls.First(c => c.Type == "TextBox" && c.Y >= 600).Model.Props.GetText("TabIndex") is null or "0");
// 정리
designer.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 600).ToList());
designer.DeleteSelection();
// 20-6) 전체 속성 그리드(고급) — 토글 → raw 편집 → 속성 추가
var advTarget = Find("TextBox");
designer.Selection.SetSingle(advTarget);
var toggleRow = designer.Inspector.Rows
.OfType<ViewModels.Inspector.ToggleAdvancedRowViewModel>().FirstOrDefault();
Check("고급: 토글 행 존재", toggleRow is not null);
toggleRow!.ToggleCommand!.Execute(null, EventArgs.Empty);
var advRows = designer.Inspector.Rows
.OfType<ViewModels.Inspector.TextRowViewModel>()
.Where(r => r.Label is "LocationOnBase" or "TabIndex" or "DisplaySequence")
.ToList();
Check("고급: raw 속성 행 노출", designer.Inspector.Rows
.OfType<ViewModels.Inspector.SectionRowViewModel>()
.Any(r => r.Label.StartsWith("전체 속성")));
var addRow = designer.Inspector.Rows
.OfType<ViewModels.Inspector.AddPropertyRowViewModel>().FirstOrDefault();
Check("고급: 속성 추가 행 존재", addRow is not null);
addRow!.KeyText = "EnterTabYon";
addRow.AddCommand!.Execute(null, EventArgs.Empty);
Check("고급: 속성 추가됨", advTarget.Model.Props.Contains("EnterTabYon"));
var enterTabRow = designer.Inspector.Rows
.OfType<ViewModels.Inspector.TextRowViewModel>()
.FirstOrDefault(r => r.Label == "EnterTabYon");
Check("고급: 추가 속성 행 노출", enterTabRow is not null);
enterTabRow!.ValueText = "True";
Check("고급: raw 값 커밋", advTarget.Model.Props.GetText("EnterTabYon") == "True");
// 21) JSON 왕복(부 포맷) — 편집 결과 유지
var jsonSerializer = new Core.Serialization.FormJsonSerializer();
var json = jsonSerializer.Write(designer.Document);
var fromJson = jsonSerializer.Read(json);
Check("JSON 왕복 — XML 동일", serializer.Write(fromJson) == serializer.Write(designer.Document));
// 22) 연속 Undo 로 빈 문서까지
var guard = 0;
while (designer.Undo.CanUndo && guard++ < 80)
{
designer.Undo.Undo();
}
Check("전체 Undo → 빈 문서", designer.Document.Pages[0].Controls.Count == 0
&& designer.Document.Pages.Count == 1,
$"실제 컨트롤 {designer.Document.Pages[0].Controls.Count} 페이지 {designer.Document.Pages.Count}");
}
catch (Exception ex)
{
failed++;
lines.Add("EXCEPTION " + ex);
}
lines.Add($"결과: 실패 {failed}건");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return failed == 0 ? 0 : 1;
}
#endregion
}
@@ -0,0 +1,44 @@
using System.IO;
using Microsoft.Extensions.Configuration;
using SheetMe.Data.Config;
namespace SheetMe.Designer.Services;
/// <summary>
/// appsettings → DataConfig 바인딩 로더.
/// 우선순위: 환경변수 &gt; appsettings.Development.json &gt; appsettings.json.
/// 커밋되는 appsettings.json 은 <c>__HOST__</c> 류 플레이스홀더만 담으며, 실접속 정보는
/// appsettings.Development.json(개발, Debug 빌드에서만 산출물 복사) 또는 환경변수로 주입한다.
/// </summary>
public static class ConfigLoader
{
#region Methods
/// <summary>설정 로드 — 설정이 없으면 기본값(File 모드)</summary>
public static DataConfig Load()
{
var config = new DataConfig();
var basePath = AppContext.BaseDirectory;
var root = new ConfigurationBuilder()
.SetBasePath(basePath)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
var connectionString = root.GetConnectionString("His") ?? string.Empty;
config.ConnectionString = IsPlaceholder(connectionString) ? string.Empty : connectionString;
config.Provider = root["His:Provider"] ?? "Oracle";
config.SaveMode = root["FormStore:SaveMode"] ?? "File";
config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms";
return config;
}
/// <summary>
/// 커밋본의 미치환 플레이스홀더인지 — 이 경우 '미설정'으로 간주해 DB 기능을 끈다.
/// 플레이스홀더를 그대로 접속에 쓰면 무의미한 연결 실패 예외가 사용자에게 노출된다.
/// </summary>
private static bool IsPlaceholder(string connectionString) =>
connectionString.Length == 0 || connectionString.Contains("__", StringComparison.Ordinal);
#endregion
}
@@ -0,0 +1,32 @@
using Microsoft.Win32;
namespace SheetMe.Designer.Services;
/// <summary>파일 대화상자 래퍼 — ViewModel 에서 View 기술 의존을 격리.</summary>
public sealed class DialogService
{
#region Methods
/// <summary>열기 대화상자 — 취소 시 null</summary>
public string? ShowOpenFile(string filter, string title)
{
var dialog = new OpenFileDialog
{
Filter = filter,
Title = title,
};
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
/// <summary>저장 대화상자 — 취소 시 null</summary>
public string? ShowSaveFile(string filter, string title, string defaultFileName)
{
var dialog = new SaveFileDialog
{
Filter = filter,
Title = title,
FileName = defaultFileName,
};
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
#endregion
}
@@ -0,0 +1,77 @@
using System.Windows.Media;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Services;
/// <summary>
/// 모델 ↔ ViewModel 매핑 팩토리.
/// z-order 규약: 모델(=XML Object 순서)은 index 0 이 최상위(WinForms Controls 규약),
/// WPF Canvas 는 나중에 그린 것이 위 — 따라서 VM 컬렉션은 모델의 역순(그리기 순서)이다.
/// </summary>
public static class DocumentMapper
{
#region Methods
/// <summary>페이지 모델 → 페이지 VM (컨트롤 트리 포함)</summary>
public static PageViewModel CreatePage(FormPage page, int index)
{
var pageViewModel = new PageViewModel(page, index);
// 루트 폰트/글자색이 상속의 기점 (실샘플: 루트 Font "굴림, 11.25pt")
var rootFontText = page.Root.Props.GetText("Font");
var rootFont = rootFontText is not null ? LegacyFormat.ParseFont(rootFontText) : new LegacyFont();
var rootForeground = Brushes.Black;
foreach (var model in PaintOrder(page.Controls))
{
pageViewModel.Controls.Add(CreateControl(model, rootFont, rootForeground));
}
return pageViewModel;
}
/// <summary>컨트롤 모델 → 타입별 VM (자식·시각 컨텍스트 포함)</summary>
public static ControlViewModel CreateControl(ControlElement model, LegacyFont parentFont, Brush parentForeground)
{
ControlViewModel viewModel = model.Type switch
{
"Label" => new LabelViewModel(model),
"TextBox" => new TextBoxViewModel(model),
"MaskedTextBox" => new MaskedTextBoxViewModel(model),
"CheckBox" => new CheckBoxViewModel(model),
"RadioButton" => new RadioButtonViewModel(model),
"ComboBox" => new ComboBoxViewModel(model),
"ListBox" => new ListBoxViewModel(model),
"CheckList" => new CheckListViewModel(model),
"DateTimePicker" => new DateTimePickerViewModel(model),
"Panel" => new PanelViewModel(model),
"GroupBox" => new GroupBoxViewModel(model),
"Line" => new LineViewModel(model),
"PictureBox" => new PictureBoxViewModel(model),
"CalcBox" => new CalcBoxViewModel(model),
"Button" => new ButtonViewModel(model),
"DataTable" => new DataTableViewModel(model),
"Spread" => new SpreadViewModel(model),
_ => new PlaceholderViewModel(model),
};
viewModel.ResolveVisualContext(parentFont, parentForeground);
if (viewModel is ContainerViewModel container)
{
foreach (var child in PaintOrder(model.Children))
{
var childViewModel = CreateControl(child, viewModel.EffectiveFont, viewModel.Foreground);
childViewModel.Parent = container;
container.Children.Add(childViewModel);
}
}
return viewModel;
}
/// <summary>모델 순서(0=최상위) → 그리기 순서(마지막=최상위)로 역전</summary>
private static IEnumerable<ControlElement> PaintOrder(List<ControlElement> models)
=> Enumerable.Reverse(models);
#endregion
}
@@ -0,0 +1,49 @@
using SheetMe.Core.Models;
namespace SheetMe.Designer.Services;
/// <summary>컨트롤 Id 생성 — 문서 전체에서 유일한 "TextBox1" 식 이름 부여(레거시 관행).</summary>
public static class IdGenerator
{
#region Methods
/// <summary>문서의 사용 중 Id 집합 수집</summary>
public static HashSet<string> CollectUsed(FormDocument document)
{
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var page in document.Pages)
{
Collect(page.Root, used);
}
return used;
}
/// <summary>타입 기반 유일 Id 생성 — 발급한 Id 는 used 집합에 추가된다(연속 발급 안전)</summary>
public static string NextId(HashSet<string> used, string type)
{
for (var i = 1; ; i++)
{
var candidate = type + i;
if (used.Add(candidate))
{
return candidate;
}
}
}
/// <summary>단건 발급 편의 — 문서 스캔 후 1개 생성</summary>
public static string NextId(FormDocument document, string type)
=> NextId(CollectUsed(document), type);
private static void Collect(ControlElement element, HashSet<string> used)
{
if (element.Id.Length > 0)
{
used.Add(element.Id);
}
foreach (var child in element.Children)
{
Collect(child, used);
}
}
#endregion
}
@@ -0,0 +1,422 @@
using System.Windows;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Services;
/// <summary>포인터 컨텍스트 — 수정키/클릭 수</summary>
public readonly record struct PointerContext(bool Ctrl, bool Shift, bool Alt, int ClickCount);
/// <summary>커서 종류 — 뷰(Behavior)가 WPF Cursor 로 매핑</summary>
public enum CursorKind
{
Arrow,
SizeAll,
SizeNWSE,
SizeNESW,
SizeNS,
SizeWE,
}
/// <summary>
/// 마우스 상호작용 상태머신 — 월드 좌표만 다루는 순수 로직(뷰 비의존).
/// 상태: None → PendingMove(임계 대기) → Move / Resize / Marquee.
/// 드래그 = Undo 1스텝(첫 실이동 시 스냅샷), 절대좌표 재계산(드리프트 방지).
/// </summary>
public sealed class InteractionController
{
#region Member Fields
private enum Mode { None, PendingMove, Move, Resize, Marquee }
private const double DragThreshold = 2;
private const double MinSize = 8;
private readonly DesignerViewModel designer;
private Mode mode = Mode.None;
private Point downPoint;
private ControlViewModel? downControl;
private bool toggleOnUp;
private bool collapseOnUp;
private int resizeHandleIndex = -1;
private Rect groupBounds0;
private readonly Dictionary<ControlViewModel, Rect> origBounds = new();
private bool undoCaptured;
#endregion
#region Constructors
public InteractionController(DesignerViewModel designer)
{
this.designer = designer;
}
#endregion
#region Methods - Pointer
/// <summary>마우스 다운(월드 좌표)</summary>
public void PointerDown(Point world, PointerContext ctx)
{
// 탭순서 편집 모드 — 클릭은 순번 지정으로만 동작(드래그/핸들/마퀴 차단)
if (designer.IsTabOrderMode)
{
var tabHit = designer.HitTestControl(world);
if (tabHit is not null)
{
designer.ToggleTabOrderClick(tabHit);
}
mode = Mode.None;
return;
}
toggleOnUp = false;
collapseOnUp = false;
undoCaptured = false;
downPoint = world;
// 1) 핸들 히트 — 선택 bbox 의 8핸들
var handleIndex = HandleAt(world);
if (handleIndex >= 0)
{
mode = Mode.Resize;
resizeHandleIndex = handleIndex;
CaptureOrigBounds();
return;
}
// 2) 컨트롤 히트 — 그룹 멤버면 그룹 전체 단위로 선택
var hit = designer.HitTestControl(world);
if (hit is not null)
{
// 더블클릭 → 인라인 텍스트 편집(드래그 시작 안 함)
if (ctx.ClickCount >= 2 && DesignerViewModel.IsTextEditable(hit))
{
mode = Mode.None;
designer.RequestInlineEdit(hit);
return;
}
downControl = hit;
if (ctx.Ctrl || ctx.Shift)
{
toggleOnUp = true; // 드래그 없이 업이면 토글
}
else if (!hit.IsSelected)
{
designer.Selection.Set(designer.GroupMatesOf(hit), hit);
}
else if (designer.Selection.Items.Count > designer.GroupMatesOf(hit).Count)
{
collapseOnUp = true; // 그룹보다 넓은 다중선택에서 클릭만 하면 그룹/단일로 축소
}
mode = Mode.PendingMove;
return;
}
// 3) 빈 곳 — 마퀴
if (!ctx.Ctrl && !ctx.Shift)
{
designer.Selection.Clear();
}
mode = Mode.Marquee;
designer.Overlay.UpdateMarquee(new Rect(world, world));
}
/// <summary>마우스 이동(월드 좌표)</summary>
public void PointerMove(Point world, PointerContext ctx)
{
switch (mode)
{
case Mode.PendingMove:
if (Distance(world, downPoint) > DragThreshold && downControl is not null)
{
if (downControl.Model.Locked)
{
return;
}
mode = Mode.Move;
toggleOnUp = false;
collapseOnUp = false;
BeginMove();
DoMove(world, ctx); // 전환 프레임부터 즉시 추종
}
break;
case Mode.Move:
DoMove(world, ctx);
break;
case Mode.Resize:
DoResize(world, ctx);
break;
case Mode.Marquee:
designer.Overlay.UpdateMarquee(RectFrom(downPoint, world));
break;
}
}
/// <summary>마우스 업(월드 좌표)</summary>
public void PointerUp(Point world, PointerContext ctx)
{
switch (mode)
{
case Mode.PendingMove:
if (toggleOnUp && downControl is not null)
{
// 그룹 단위 토글
var mates = designer.GroupMatesOf(downControl);
if (downControl.IsSelected)
{
designer.Selection.Set(designer.Selection.Items.Except(mates).ToList());
}
else
{
designer.Selection.Set(designer.Selection.Items.Union(mates).ToList(), downControl);
}
}
else if (collapseOnUp && downControl is not null)
{
designer.Selection.Set(designer.GroupMatesOf(downControl), downControl);
}
break;
case Mode.Move:
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
designer.Overlay.SetGuides(null, null);
break;
case Mode.Resize:
designer.Overlay.SetGuides(null, null);
break;
case Mode.Marquee:
var rect = RectFrom(downPoint, world);
designer.Overlay.UpdateMarquee(null);
var hits = designer.ControlsIntersecting(rect)
.SelectMany(designer.GroupMatesOf)
.Distinct()
.ToList();
if (ctx.Ctrl || ctx.Shift)
{
var merged = designer.Selection.Items.Union(hits).ToList();
designer.Selection.Set(merged);
}
else if (hits.Count > 0)
{
designer.Selection.Set(hits);
}
break;
}
mode = Mode.None;
downControl = null;
origBounds.Clear();
designer.RefreshOverlay();
}
/// <summary>
/// 진행 중 드래그만 취소(원위치 복원) — 캡처 상실 등 비정상 종료용.
/// 유휴 상태에서는 아무것도 하지 않는다(정상 마우스 업 후 캡처 해제가 선택을 지우면 안 됨).
/// </summary>
public void CancelDrag()
{
if (mode is Mode.Move or Mode.Resize)
{
foreach (var (vm, rect) in origBounds)
{
var offset = designer.ParentWorldOffset(vm);
vm.X = rect.X - offset.X;
vm.Y = rect.Y - offset.Y;
vm.Width = rect.Width;
vm.Height = rect.Height;
}
designer.Overlay.SetGuides(null, null);
mode = Mode.None;
origBounds.Clear();
designer.RefreshOverlay();
}
else if (mode is Mode.Marquee or Mode.PendingMove)
{
designer.Overlay.UpdateMarquee(null);
mode = Mode.None;
}
}
/// <summary>Esc — 탭순서 모드 취소 / 진행 중 드래그 취소 / 유휴 상태면 선택 해제</summary>
public void Cancel()
{
if (designer.IsTabOrderMode)
{
designer.CancelTabOrderMode();
return;
}
if (mode == Mode.None)
{
designer.Selection.Clear();
return;
}
CancelDrag();
}
/// <summary>커서 판정(호버 피드백)</summary>
public CursorKind CursorAt(Point world)
{
var handle = HandleAt(world);
if (handle >= 0)
{
return handle switch
{
0 or 4 => CursorKind.SizeNWSE,
2 or 6 => CursorKind.SizeNESW,
1 or 5 => CursorKind.SizeNS,
_ => CursorKind.SizeWE,
};
}
if (mode == Mode.Move)
{
return CursorKind.SizeAll;
}
return designer.HitTestControl(world) is not null ? CursorKind.SizeAll : CursorKind.Arrow;
}
#endregion
#region Methods - Private
private void BeginMove()
{
CaptureOrigBounds();
var page = designer.PageOf(designer.Selection.Primary!) ?? designer.Pages.FirstOrDefault();
if (page is not null)
{
designer.Snap.BeginDrag(page, designer.Selection.Items.ToHashSet());
}
}
private void DoMove(Point world, PointerContext ctx)
{
EnsureUndoSnapshot();
var dx = world.X - downPoint.X;
var dy = world.Y - downPoint.Y;
var snap = designer.Snap.SnapMove(groupBounds0, dx, dy, ctx.Alt);
designer.Overlay.SetGuides(snap.GuideX, snap.GuideY);
// origBounds 는 월드 좌표 — 로컬 = (원본 월드 + 보정 델타) - 부모 체인 오프셋
foreach (var (vm, rect) in origBounds)
{
var offset = designer.ParentWorldOffset(vm);
vm.X = Math.Round(rect.X + snap.Dx - offset.X);
vm.Y = Math.Round(rect.Y + snap.Dy - offset.Y);
}
designer.Selection.NotifyBoundsChanged();
}
private void DoResize(Point world, PointerContext ctx)
{
EnsureUndoSnapshot();
var rect = groupBounds0;
var left = rect.Left;
var top = rect.Top;
var right = rect.Right;
var bottom = rect.Bottom;
// 핸들별 이동 모서리 (0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)
if (resizeHandleIndex is 0 or 6 or 7)
{
left = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
}
if (resizeHandleIndex is 2 or 3 or 4)
{
right = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
}
if (resizeHandleIndex is 0 or 1 or 2)
{
top = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
}
if (resizeHandleIndex is 4 or 5 or 6)
{
bottom = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
}
var newRect = new Rect(
Math.Min(left, right - MinSize),
Math.Min(top, bottom - MinSize),
Math.Max(MinSize, right - left),
Math.Max(MinSize, bottom - top));
// 그룹 비례 스케일 — origBounds 기준 절대 재계산
var sx = newRect.Width / Math.Max(1, groupBounds0.Width);
var sy = newRect.Height / Math.Max(1, groupBounds0.Height);
foreach (var (vm, orig) in origBounds)
{
var offset = designer.ParentWorldOffset(vm);
vm.X = Math.Round(newRect.X + (orig.X - groupBounds0.X) * sx - offset.X);
vm.Y = Math.Round(newRect.Y + (orig.Y - groupBounds0.Y) * sy - offset.Y);
vm.Width = Math.Max(1, Math.Round(orig.Width * sx));
vm.Height = Math.Max(1, Math.Round(orig.Height * sy));
}
designer.Selection.NotifyBoundsChanged();
}
/// <summary>드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝</summary>
private void EnsureUndoSnapshot()
{
if (!undoCaptured)
{
designer.Undo.Snapshot();
undoCaptured = true;
}
}
/// <summary>선택 항목의 월드 bbox·개별 원본 bounds 캡처(X 는 페이지 로컬 = 월드 X, Y 는 월드)</summary>
private void CaptureOrigBounds()
{
origBounds.Clear();
foreach (var vm in designer.Selection.Items)
{
origBounds[vm] = designer.WorldBoundsOf(vm);
}
groupBounds0 = designer.SelectionWorldBounds() ?? Rect.Empty;
}
private int HandleAt(Point world)
{
var overlay = designer.Overlay;
if (!overlay.HasSelection || !overlay.ShowHandles)
{
return -1;
}
var bbox = new Rect(overlay.SelX, overlay.SelY, overlay.SelW, overlay.SelH);
var positions = SelectionOverlayViewModel.HandlePositions(bbox);
// 반경 내 '최근접' 핸들 선택 — 작은 컨트롤에서 인접 핸들 오인 방지
const double hitRadius = 6;
var best = -1;
var bestDistance = double.MaxValue;
for (var i = 0; i < positions.Length; i++)
{
var dx = Math.Abs(world.X - positions[i].X);
var dy = Math.Abs(world.Y - positions[i].Y);
if (dx > hitRadius || dy > hitRadius)
{
continue;
}
var distance = dx * dx + dy * dy;
if (distance < bestDistance)
{
bestDistance = distance;
best = i;
}
}
return best;
}
private static double Distance(Point a, Point b)
=> Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
private static Rect RectFrom(Point a, Point b)
=> new(Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y));
#endregion
}
@@ -0,0 +1,87 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Services;
/// <summary>
/// 인쇄 서비스 — 페이지당 FixedPage 1:1(DIP)로 조립해 인쇄.
/// 캔버스와 동일한 DataTemplate 사전(App 리소스)을 사용하므로 화면=인쇄 렌더가 일치한다.
/// 용지 그림자 등 편집 크롬 없이 흰 배경 + 컨트롤만 그린다.
/// </summary>
public static class PrintService
{
#region Methods
/// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary>
public static void Print(DesignerViewModel designer, string documentName)
{
var dialog = new System.Windows.Controls.PrintDialog();
if (dialog.ShowDialog() != true)
{
return;
}
var document = BuildFixedDocument(designer);
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
}
/// <summary>페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용)</summary>
public static FixedDocument BuildFixedDocument(DesignerViewModel designer)
{
var document = new FixedDocument();
foreach (var page in designer.Pages)
{
var fixedPage = new FixedPage
{
Width = page.WidthDip,
Height = page.HeightDip,
Background = page.PaperBrush,
};
fixedPage.Children.Add(BuildPageVisual(page));
var pageContent = new PageContent();
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
document.Pages.Add(pageContent);
}
return document;
}
/// <summary>페이지 컨트롤층 비주얼 — 크롬 없는 Canvas(그리기 순서 = 컬렉션 순서, 미리보기/인쇄 공용)</summary>
public static UIElement BuildPageVisual(PageViewModel page)
{
var canvas = new Canvas
{
Width = page.WidthDip,
Height = page.HeightDip,
};
TextOptions.SetTextFormattingMode(canvas, TextFormattingMode.Ideal);
// 종이 위 렌더는 레거시 충실 유지 — 앱 테마의 암시 TextBlock 스타일(다크 밝은 글자) 차단
var paperText = new Style(typeof(TextBlock));
paperText.Setters.Add(new Setter(TextBlock.ForegroundProperty, Brushes.Black));
canvas.Resources.Add(typeof(TextBlock), paperText);
foreach (var control in page.Controls)
{
// 숨김 + 데이터소스(MDataTable — 런타임 비가시)는 인쇄/미리보기에서 제외
if (control.Model.Hidden || control is DataTableViewModel)
{
continue;
}
var presenter = new ContentPresenter
{
Content = control,
Width = Math.Max(1, control.Width),
Height = Math.Max(1, control.Height),
};
Canvas.SetLeft(presenter, control.X);
Canvas.SetTop(presenter, control.Y);
canvas.Children.Add(presenter);
}
return canvas;
}
#endregion
}
@@ -0,0 +1,82 @@
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Services;
/// <summary>선택 집합 관리 — 다중선택 + Primary. 변경 시 IsSelected 플래그 동기화 및 Changed 통지.</summary>
public sealed class SelectionService
{
#region Member Fields
private readonly List<ControlViewModel> items = new();
#endregion
#region Properties
/// <summary>선택된 컨트롤 목록</summary>
public IReadOnlyList<ControlViewModel> Items => items;
/// <summary>기준(Primary) 선택</summary>
public ControlViewModel? Primary { get; private set; }
/// <summary>Primary 가 속한 페이지 — 컨트롤 추가 대상 결정용</summary>
public PageViewModel? ActivePage { get; set; }
#endregion
#region Events
/// <summary>선택 변경 통지(경계 이동 포함) — 오버레이 구독</summary>
public event Action? Changed;
/// <summary>선택 '집합' 변경 통지(Set/Toggle/Clear 만) — 인스펙터 재구성 구독(드래그 프레임 제외)</summary>
public event Action? SetChanged;
#endregion
#region Methods
/// <summary>선택 교체</summary>
public void Set(IEnumerable<ControlViewModel> newItems, ControlViewModel? primary = null)
{
foreach (var item in items)
{
item.IsSelected = false;
}
items.Clear();
foreach (var item in newItems.Where(i => !i.Model.Locked))
{
items.Add(item);
item.IsSelected = true;
}
Primary = primary is not null && items.Contains(primary) ? primary : items.FirstOrDefault();
Changed?.Invoke();
SetChanged?.Invoke();
}
/// <summary>단일 선택</summary>
public void SetSingle(ControlViewModel item) => Set(new[] { item }, item);
/// <summary>토글(Ctrl/Shift+클릭)</summary>
public void Toggle(ControlViewModel item)
{
if (items.Contains(item))
{
items.Remove(item);
item.IsSelected = false;
if (Primary == item)
{
Primary = items.FirstOrDefault();
}
}
else if (!item.Model.Locked)
{
items.Add(item);
item.IsSelected = true;
Primary = item;
}
Changed?.Invoke();
SetChanged?.Invoke();
}
/// <summary>선택 해제</summary>
public void Clear() => Set(Array.Empty<ControlViewModel>());
/// <summary>이동/리사이즈 중 오버레이 갱신 트리거</summary>
public void NotifyBoundsChanged() => Changed?.Invoke();
#endregion
}
+125
View File
@@ -0,0 +1,125 @@
using System.Windows;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Services;
/// <summary>스냅 결과 — 보정된 이동량과 표시할 가이드 좌표(월드)</summary>
public readonly record struct SnapResult(double Dx, double Dy, double? GuideX, double? GuideY);
/// <summary>
/// 그리드/정렬 스냅 순수 계산.
/// 드래그 시작 시 후보(비선택 형제의 좌/중/우·상/중/하 + 용지 경계/중앙)를 1회 사전수집해
/// 프레임당 재수집을 피한다.
/// </summary>
public sealed class SnapEngine
{
#region Member Fields
private readonly List<double> candidatesX = new();
private readonly List<double> candidatesY = new();
#endregion
#region Properties
/// <summary>그리드 간격(px)</summary>
public double GridSize { get; set; } = 4;
/// <summary>정렬 스냅 허용 거리(px)</summary>
public double Tolerance { get; set; } = 6;
#endregion
#region Methods
/// <summary>드래그 시작 — 스냅 후보 사전수집(월드 좌표)</summary>
public void BeginDrag(PageViewModel page, IReadOnlySet<ControlViewModel> moving)
{
candidatesX.Clear();
candidatesY.Clear();
// 용지 경계·중앙
candidatesX.Add(0);
candidatesX.Add(page.WidthDip / 2);
candidatesX.Add(page.WidthDip);
candidatesY.Add(page.OffsetY);
candidatesY.Add(page.OffsetY + page.HeightDip / 2);
candidatesY.Add(page.OffsetY + page.HeightDip);
foreach (var sibling in page.Controls)
{
if (moving.Contains(sibling) || sibling.Model.Hidden)
{
continue;
}
candidatesX.Add(sibling.X);
candidatesX.Add(sibling.X + sibling.Width / 2);
candidatesX.Add(sibling.X + sibling.Width);
var top = page.OffsetY + sibling.Y;
candidatesY.Add(top);
candidatesY.Add(top + sibling.Height / 2);
candidatesY.Add(top + sibling.Height);
}
}
/// <summary>
/// 이동 스냅 — bbox(월드)에 (dx,dy) 적용 시 정렬 후보와 최근접 정합.
/// free=true(Alt)면 스냅 없이 원본 이동량 그대로.
/// </summary>
public SnapResult SnapMove(Rect bbox, double dx, double dy, bool free)
{
if (free)
{
return new SnapResult(dx, dy, null, null);
}
var movedLeft = bbox.X + dx;
var movedTop = bbox.Y + dy;
var (adjustX, guideX) = SnapAxis(new[] { movedLeft, movedLeft + bbox.Width / 2, movedLeft + bbox.Width }, candidatesX);
var (adjustY, guideY) = SnapAxis(new[] { movedTop, movedTop + bbox.Height / 2, movedTop + bbox.Height }, candidatesY);
// 정합 실패 축은 그리드 스냅
var resultDx = guideX is not null ? dx + adjustX : Math.Round(movedLeft / GridSize) * GridSize - bbox.X;
var resultDy = guideY is not null ? dy + adjustY : Math.Round(movedTop / GridSize) * GridSize - bbox.Y;
return new SnapResult(resultDx, resultDy, guideX, guideY);
}
/// <summary>리사이즈 스냅 — 움직이는 모서리 좌표만 정합(간이: 그리드 우선)</summary>
public double SnapEdge(double value, bool isXAxis, bool free)
{
if (free)
{
return value;
}
var candidates = isXAxis ? candidatesX : candidatesY;
foreach (var candidate in candidates)
{
if (Math.Abs(candidate - value) <= Tolerance)
{
return candidate;
}
}
return Math.Round(value / GridSize) * GridSize;
}
private (double Adjust, double? Guide) SnapAxis(double[] edges, List<double> candidates)
{
var best = double.MaxValue;
double adjust = 0;
double? guide = null;
foreach (var edge in edges)
{
foreach (var candidate in candidates)
{
var distance = candidate - edge;
if (Math.Abs(distance) <= Tolerance && Math.Abs(distance) < Math.Abs(best))
{
best = distance;
adjust = distance;
guide = candidate;
}
}
}
return (adjust, guide);
}
#endregion
}
@@ -0,0 +1,80 @@
using System.IO;
using System.Text.Json;
using System.Windows;
namespace SheetMe.Designer.Services;
/// <summary>
/// 라이트/다크 테마 전환 — App.Resources 의 토큰 사전(Tokens.Dark↔Light)을 교체하면
/// {DynamicResource B.*} 를 참조하는 모든 스타일이 라이브 리스킨된다([200]SheetMe SwapThemeTokens 이식).
/// 선택은 %LocalAppData%\SheetMe\theme.json 에 보존.
/// </summary>
public static class ThemeManager
{
#region Member Fields
private static readonly string SettingsPath =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SheetMe", "theme.json");
#endregion
#region Properties
/// <summary>현재 라이트 테마 여부(기본 다크 — [200]SheetMe 기본값과 동일)</summary>
public static bool IsLight { get; private set; }
#endregion
#region Methods
/// <summary>시작 시 저장된 테마 적용</summary>
public static void LoadSaved()
{
try
{
if (File.Exists(SettingsPath) &&
JsonDocument.Parse(File.ReadAllText(SettingsPath)).RootElement.TryGetProperty("theme", out var theme) &&
theme.GetString() == "light")
{
Apply(light: true);
}
}
catch
{
// 설정 손상 시 기본(다크) 유지
}
}
/// <summary>라이트↔다크 토글</summary>
public static void Toggle() => Apply(!IsLight);
/// <summary>테마 적용 — App.Resources 병합 사전에서 토큰 dict 를 찾아 교체</summary>
public static void Apply(bool light)
{
IsLight = light;
var dictionaries = Application.Current.Resources.MergedDictionaries;
var tokensUri = new Uri($"/Themes/Tokens.{(light ? "Light" : "Dark")}.xaml", UriKind.Relative);
for (var i = 0; i < dictionaries.Count; i++)
{
var source = dictionaries[i].Source?.OriginalString ?? string.Empty;
if (source.EndsWith("Tokens.Dark.xaml", StringComparison.OrdinalIgnoreCase) ||
source.EndsWith("Tokens.Light.xaml", StringComparison.OrdinalIgnoreCase))
{
dictionaries[i] = new ResourceDictionary { Source = tokensUri };
Save();
return;
}
}
dictionaries.Add(new ResourceDictionary { Source = tokensUri });
Save();
}
private static void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(new { theme = IsLight ? "light" : "dark" }));
}
catch
{
// 저장 실패는 무시(다음 실행 기본 테마)
}
}
#endregion
}
@@ -0,0 +1,94 @@
using SheetMe.Core.Models;
namespace SheetMe.Designer.Services;
/// <summary>
/// 문서 스냅샷(메멘토) Undo/Redo — 직렬화 대신 모델 딥클론(편집 전용 상태 포함) 사용.
/// 규약: 변경 '직전' Snapshot() 1회. 드래그는 첫 실이동에서 1회 = 드래그 전체가 1스텝.
/// 방향키 넛지는 400ms 코얼레스.
/// </summary>
public sealed class UndoService
{
#region Member Fields
private const int Capacity = 100;
private readonly List<FormDocument> undoStack = new();
private readonly List<FormDocument> redoStack = new();
private readonly Func<FormDocument> getDocument;
private readonly Action<FormDocument> restoreDocument;
private DateTime lastNudgeAt = DateTime.MinValue;
#endregion
#region Properties
/// <summary>Undo 가능 여부</summary>
public bool CanUndo => undoStack.Count > 0;
/// <summary>Redo 가능 여부</summary>
public bool CanRedo => redoStack.Count > 0;
#endregion
#region Constructors
public UndoService(Func<FormDocument> getDocument, Action<FormDocument> restoreDocument)
{
this.getDocument = getDocument;
this.restoreDocument = restoreDocument;
}
#endregion
#region Events
/// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary>
public event Action? Restored;
#endregion
#region Methods
/// <summary>변경 직전 스냅샷 적재 — redo 클리어</summary>
public void Snapshot()
{
undoStack.Add(getDocument().Clone());
if (undoStack.Count > Capacity)
{
undoStack.RemoveRange(0, undoStack.Count - Capacity);
}
redoStack.Clear();
lastNudgeAt = DateTime.MinValue;
}
/// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary>
public void SnapshotForNudge()
{
var now = DateTime.UtcNow;
if ((now - lastNudgeAt).TotalMilliseconds > 400)
{
Snapshot();
}
lastNudgeAt = now;
}
/// <summary>실행 취소</summary>
public void Undo()
{
if (!CanUndo)
{
return;
}
redoStack.Add(getDocument().Clone());
var snapshot = undoStack[^1];
undoStack.RemoveAt(undoStack.Count - 1);
restoreDocument(snapshot);
Restored?.Invoke();
}
/// <summary>다시 실행</summary>
public void Redo()
{
if (!CanRedo)
{
return;
}
undoStack.Add(getDocument().Clone());
var snapshot = redoStack[^1];
redoStack.RemoveAt(redoStack.Count - 1);
restoreDocument(snapshot);
Restored?.Invoke();
}
#endregion
}
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<OutputType>WinExe</OutputType>
<UseWPF>true</UseWPF>
<RootNamespace>SheetMe.Designer</RootNamespace>
<AssemblyName>SheetMe.Designer</AssemblyName>
<ApplicationIcon>Assets\sheetme.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Resource Include="Assets\sheetme.ico" />
<Resource Include="Assets\sheetme-logo.png" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SheetMe.Core\SheetMe.Core.csproj" />
<ProjectReference Include="..\SheetMe.Data\SheetMe.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="M.Framework.WPF" Version="6.5.3" />
<PackageReference Include="M.Framework.LogManager" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<!-- 실접속 정보는 Debug 빌드에서만 산출물에 복사한다 — Release 퍼블리시에 비밀값이 실리지 않게. -->
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<None Update="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,806 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework">
<!-- ============================================================
디자이너 공유 테마 (암시 스타일 + 컨트롤 템플릿)
· 색상은 {DynamicResource B.*} 로 참조 → 토큰 dict(Tokens.Dark/Light.xaml) 교체로 라이트/다크 전환
· MainWindow.Resources 에 [이 파일 + 토큰 dict] 머지(토큰 dict 만 스왑)
· 스타일 키(Subtle/CaptionBtn/B.ComboItem/ScrollThumb…)는 StaticResource 유지
============================================================ -->
<!-- ===== 창 공통(다이얼로그 배경/폰트 — 로컬 지정이 있으면 로컬 우선) ===== -->
<Style TargetType="Window">
<Setter Property="Background" Value="{DynamicResource B.Panel}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
</Style>
<!-- ===== 타이포 ===== -->
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
</Style>
<!-- ===== 버튼 (기본: 은은한 다크 채움) ===== -->
<Style TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="Background" Value="{DynamicResource B.Input}" />
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
<Setter Property="Padding" Value="10,5" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
<ContentPresenter.Resources>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
</Style>
</ContentPresenter.Resources>
</ContentPresenter>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.BtnHover}" />
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.BtnPressed}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="bd" Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 강조 버튼 -->
<Style x:Key="Primary" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="{DynamicResource B.Accent}" />
<Setter Property="Foreground" Value="{DynamicResource B.OnAccent}" />
<Setter Property="BorderBrush" Value="{DynamicResource B.Accent}" />
</Style>
<!-- 텍스트형(테두리 없는) 버튼 — 툴바 아이콘/네비 -->
<Style x:Key="Subtle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
</Style>
<!-- ===== 텍스트박스 ===== -->
<!-- Framer 식 입력칩: 채움만(평소 테두리 없음)·라운드8·높이30·세로 가운데 -->
<Style TargetType="TextBox">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="BorderBrush" Value="{DynamicResource B.InputBorder}" />
<Setter Property="Background" Value="{DynamicResource B.Input}" />
<Setter Property="Padding" Value="4,0" />
<Setter Property="MinHeight" Value="30" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CaretBrush" Value="{DynamicResource B.Accent}" />
<Setter Property="SelectionBrush" Value="{DynamicResource B.Accent}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="8" SnapsToDevicePixels="True">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
</Trigger>
<Trigger Property="AcceptsReturn" Value="True">
<Setter TargetName="PART_ContentHost" Property="VerticalContentAlignment" Value="Stretch" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Framer 식 슬라이더(불투명도 등): 얇은 트랙 + 흰 원형 썸 -->
<Style TargetType="Slider">
<Setter Property="MinHeight" Value="20" />
<Setter Property="IsMoveToPointEnabled" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Slider">
<Grid VerticalAlignment="Center">
<Border Height="4" CornerRadius="2" Background="{DynamicResource B.Chip}" VerticalAlignment="Center" />
<Track x:Name="PART_Track">
<Track.DecreaseRepeatButton>
<RepeatButton Command="Slider.DecreaseLarge" Focusable="False">
<RepeatButton.Template>
<ControlTemplate TargetType="RepeatButton">
<Border Height="4" CornerRadius="2" Background="{DynamicResource B.Accent}" VerticalAlignment="Center" />
</ControlTemplate>
</RepeatButton.Template>
</RepeatButton>
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton Command="Slider.IncreaseLarge" Focusable="False">
<RepeatButton.Template>
<ControlTemplate TargetType="RepeatButton"><Border Background="Transparent" /></ControlTemplate>
</RepeatButton.Template>
</RepeatButton>
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Ellipse Width="13" Height="13" Fill="White" Stroke="{DynamicResource B.SliderRing}" StrokeThickness="1" />
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 리스트 ===== -->
<Style TargetType="ListBox">
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
</Style>
<Style TargetType="ListBoxItem">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="Padding" Value="6,5" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" Background="Transparent" CornerRadius="5" Padding="{TemplateBinding Padding}" Margin="2,1">
<!-- string Content 가 앱 라이트 TextBlock 스타일(어두운색)로 떨어지지 않게 밝은색 지역 지정(ComboBoxItem 과 동일 패턴) -->
<ContentPresenter TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}">
<ContentPresenter.Resources>
<Style TargetType="TextBlock"><Setter Property="Foreground" Value="{DynamicResource B.Ink}" /></Style>
</ContentPresenter.Resources>
</ContentPresenter>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Sel}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 콤보박스 ===== -->
<Style x:Key="B.ComboItem" TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Padding" Value="8,5" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="bd" Background="Transparent" CornerRadius="4" Padding="{TemplateBinding Padding}" Margin="2,1">
<!-- 생성되는 글자 TextBlock 이 앱 라이트 TextBlock 스타일(어두운색)로 떨어지지 않게, 여기서 밝은색 스타일을 지역 지정 -->
<ContentPresenter>
<ContentPresenter.Resources>
<Style TargetType="TextBlock"><Setter Property="Foreground" Value="{DynamicResource B.Ink}" /></Style>
</ContentPresenter.Resources>
</ContentPresenter>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Sel}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 전역 암시 콤보 항목(드롭다운 밖 용도 대비) — 키 스타일 상속 -->
<Style TargetType="ComboBoxItem" BasedOn="{StaticResource B.ComboItem}" />
<Style TargetType="ComboBox">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="Background" Value="{DynamicResource B.Input}" />
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Height" Value="30" />
<!-- 드롭다운 항목 스타일을 컨테이너에 직접 지정(생성기가 적용 → 앱 라이트 ComboBoxItem 누수보다 우선) -->
<Setter Property="ItemContainerStyle" Value="{StaticResource B.ComboItem}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton Focusable="False" ClickMode="Press"
IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="bd" Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}"
BorderThickness="1" CornerRadius="8" SnapsToDevicePixels="True">
<Path x:Name="arr" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,9,0"
Data="M0,0 L4,4 L8,0" Stroke="{DynamicResource B.Muted}" StrokeThickness="1.4"
StrokeStartLineCap="Round" StrokeEndLineCap="Round" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter x:Name="cp" Margin="9,0,26,0" VerticalAlignment="Center" HorizontalAlignment="Left"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
TextElement.Foreground="{DynamicResource B.Ink}"
IsHitTestVisible="False">
<!-- 닫힌 표시 텍스트(SelectionBoxItem)가 앱 라이트 TextBlock 스타일(어두운색)로 떨어지지 않게 스코프 지정 -->
<ContentPresenter.Resources>
<Style TargetType="TextBlock"><Setter Property="Foreground" Value="{DynamicResource B.Ink}" /></Style>
</ContentPresenter.Resources>
</ContentPresenter>
<!-- 편집 가능 콤보(글꼴·형식 등)의 입력/표시 텍스트 -->
<TextBox x:Name="PART_EditableTextBox" Margin="8,0,26,0" VerticalAlignment="Center"
Foreground="{DynamicResource B.Ink}" CaretBrush="{DynamicResource B.Accent}"
Background="Transparent" Visibility="Collapsed" IsReadOnly="{TemplateBinding IsReadOnly}">
<TextBox.Template>
<ControlTemplate TargetType="TextBox">
<ScrollViewer x:Name="PART_ContentHost" Focusable="False"
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
</ControlTemplate>
</TextBox.Template>
</TextBox>
<!-- HorizontalOffset(-10)+좌우 Margin(10): 그림자 여백 확보하면서 드롭다운을 콤보 좌측에 정렬 -->
<Popup IsOpen="{TemplateBinding IsDropDownOpen}" Placement="Bottom" AllowsTransparency="True"
Focusable="False" PopupAnimation="Fade" HorizontalOffset="-10">
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
CornerRadius="5" Margin="10,3,10,12" Padding="2"
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}">
<Border.Effect>
<DropShadowEffect BlurRadius="14" ShadowDepth="2" Direction="270" Opacity="0.22" Color="#000000" RenderingBias="Quality"/>
</Border.Effect>
<!-- 드롭다운(별도 팝업)이 앱 라이트 ComboBoxItem 으로 새지 않게, 팝업 안에 다크 항목 스타일을 지역 지정 -->
<Border.Resources>
<Style TargetType="ComboBoxItem" BasedOn="{StaticResource B.ComboItem}" />
</Border.Resources>
<ScrollViewer MaxHeight="260"><ItemsPresenter /></ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEditable" Value="True">
<Setter TargetName="cp" Property="Visibility" Value="Collapsed" />
<Setter TargetName="PART_EditableTextBox" Property="Visibility" Value="Visible" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</Style.Triggers>
</Style>
<!-- ===== 탭 (좌측 패널) ===== -->
<Style TargetType="TabControl">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabControl">
<DockPanel>
<!-- 세그먼트(알약) 탭 바 — FlutterFlow 식: 둥근 컨테이너 안에 알약 탭 -->
<Border DockPanel.Dock="Top" Background="{DynamicResource B.Panel}" BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8">
<Border Background="{DynamicResource B.Chip}" CornerRadius="9" Padding="3">
<UniformGrid Rows="1" IsItemsHost="True" />
</Border>
</Border>
<ContentPresenter ContentSource="SelectedContent" />
</DockPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="TabItem">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabItem">
<Border x:Name="bd" Background="Transparent" CornerRadius="6" Margin="2,0" Padding="6,6">
<ContentPresenter ContentSource="Header" HorizontalAlignment="Center" VerticalAlignment="Center"
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.TabHover}" />
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.TabSel}" />
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontWeight" Value="Bold" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 문서 탭 (중앙 상단 — 크롬식: 라운드 상단, 활성=Surface+테두리) ===== -->
<Style x:Key="DocTabs" TargetType="TabControl">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabControl">
<DockPanel>
<Border DockPanel.Dock="Top" Background="{DynamicResource B.Titlebar}"
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8,5,8,0">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" VerticalAlignment="Bottom">
<StackPanel Orientation="Horizontal" IsItemsHost="True" />
<!-- 새 서식 탭(+) — 탭 흐름 바로 옆([200] 문서탭 관행) -->
<Button Content="" Command="{Binding NewFileCommand}" Style="{StaticResource Subtle}"
Width="26" Height="24" Padding="0" Margin="2,0,0,3" FontSize="13"
VerticalAlignment="Center" ToolTip="새 서식 탭"/>
</StackPanel>
</ScrollViewer>
</Border>
<ContentPresenter ContentSource="SelectedContent" />
</DockPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="DocTabItem" TargetType="TabItem">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabItem">
<Border x:Name="bd" CornerRadius="6,6,0,0" Padding="11,5,6,6" Margin="0,0,3,0"
Background="Transparent" BorderBrush="Transparent" BorderThickness="1,1,1,0"
SnapsToDevicePixels="True">
<ContentPresenter ContentSource="Header" VerticalAlignment="Center"
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.TabHover}" />
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Surface}" />
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line}" />
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 좌측 탭 헤더 텍스트 — 미선택=Muted, 선택=Ink+Bold(알약 위 밝은 글씨). 템플릿 색 상속 불안정 우회 -->
<Style x:Key="TabHeaderText" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
<Setter Property="FontSize" Value="12" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=TabItem}}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontWeight" Value="Bold" />
</DataTrigger>
</Style.Triggers>
</Style>
<!-- ===== 토글 버튼 ===== -->
<Style TargetType="ToggleButton">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Padding" Value="6,4" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
<ContentPresenter.Resources>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
</Style>
</ContentPresenter.Resources>
</ContentPresenter>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Sel}" />
<Setter Property="Foreground" Value="{DynamicResource B.Accent}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="bd" Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 체크박스 ===== -->
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" Background="Transparent">
<Border x:Name="box" Width="17" Height="17" CornerRadius="4" BorderThickness="1.3"
BorderBrush="{DynamicResource B.Line2}" Background="{DynamicResource B.Input}" VerticalAlignment="Center" SnapsToDevicePixels="True">
<Path x:Name="chk" Data="M 3.5 8 L 7 11 L 13 4.5" Stroke="{DynamicResource B.OnAccent}" StrokeThickness="2"
StrokeStartLineCap="Round" StrokeEndLineCap="Round" Visibility="Collapsed" />
</Border>
<ContentPresenter Margin="7,0,0,0" VerticalAlignment="Center"
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="box" Property="Background" Value="{DynamicResource B.Accent}" />
<Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource B.Accent}" />
<Setter TargetName="chk" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="box" Property="BorderBrush" Value="{DynamicResource B.Accent}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 익스팬더 (인스펙터 섹션) ===== -->
<Style TargetType="Expander">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Expander">
<DockPanel Background="Transparent">
<ToggleButton DockPanel.Dock="Top" Focusable="False" Cursor="Hand"
Content="{TemplateBinding Header}" ContentTemplate="{TemplateBinding HeaderTemplate}"
IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="hb" Background="Transparent" CornerRadius="5" Padding="2,6">
<StackPanel Orientation="Horizontal">
<Path x:Name="arrow" Data="M 0 0 L 4 4 L 8 0" Stroke="{DynamicResource B.Muted}" StrokeThickness="1.6"
Margin="2,1,8,0" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5">
<Path.RenderTransform><RotateTransform Angle="-90" /></Path.RenderTransform>
</Path>
<ContentPresenter VerticalAlignment="Center" />
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="arrow" Property="RenderTransform">
<Setter.Value><RotateTransform Angle="0" /></Setter.Value>
</Setter>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="hb" Property="Background" Value="{DynamicResource B.Hover}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter x:Name="content" DockPanel.Dock="Bottom" Visibility="Collapsed" Margin="8,2,2,10" />
</DockPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="True">
<Setter TargetName="content" Property="Visibility" Value="Visible" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 카드 / 패널헤더 / 칩 ===== -->
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="12" />
</Style>
<Style x:Key="PanelHeader" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
<Setter Property="BorderBrush" Value="{DynamicResource B.Line}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="10,7" />
</Style>
<Style x:Key="Chip" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource B.Chip}" />
<Setter Property="CornerRadius" Value="20" />
<Setter Property="Padding" Value="9,2" />
</Style>
<!-- ===== 메뉴 (타이틀바) ===== -->
<Style TargetType="Menu">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12.5" />
</Style>
<Style TargetType="Separator">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Separator">
<Border Height="1" Background="{DynamicResource B.Line}" Margin="8,4" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="MenuItem">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Padding" Value="10,6" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Grid TextElement.Foreground="{DynamicResource B.Ink}">
<Border x:Name="bd" Background="Transparent" CornerRadius="4" Padding="{TemplateBinding Padding}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ContentPresenter Grid.Column="0" x:Name="icon" ContentSource="Icon" Margin="0,0,8,0" VerticalAlignment="Center" />
<ContentPresenter Grid.Column="1" ContentSource="Header" VerticalAlignment="Center" RecognizesAccessKey="True" />
<TextBlock Grid.Column="2" x:Name="gesture" Text="{TemplateBinding InputGestureText}" Foreground="{DynamicResource B.Muted}"
Margin="20,0,0,0" VerticalAlignment="Center" />
<Path Grid.Column="2" x:Name="sub" Visibility="Collapsed" Data="M0,0 L4,4 L0,8"
Stroke="{DynamicResource B.Muted}" StrokeThickness="1.4" Margin="14,0,0,0" VerticalAlignment="Center" />
</Grid>
</Border>
<Popup x:Name="pop" Placement="Bottom" IsOpen="{TemplateBinding IsSubmenuOpen}" AllowsTransparency="True"
Focusable="False" PopupAnimation="Fade">
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
CornerRadius="6" Padding="4" Margin="0,3,0,0">
<StackPanel IsItemsHost="True" />
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="Role" Value="SubmenuHeader">
<Setter TargetName="pop" Property="Placement" Value="Right" />
<Setter TargetName="sub" Property="Visibility" Value="Visible" />
</Trigger>
<!-- 최상위 메뉴(파일/편집…)는 아이콘·단축키 칸을 접어 폭을 좁힘 -->
<Trigger Property="Role" Value="TopLevelHeader">
<Setter TargetName="icon" Property="Visibility" Value="Collapsed" />
<Setter TargetName="gesture" Property="Visibility" Value="Collapsed" />
</Trigger>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 우클릭 컨텍스트 메뉴 (다크 컨테이너) — 캔버스 우클릭 메뉴가 기본 라이트 팝업으로 안 깨지게 ===== -->
<Style TargetType="ContextMenu">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContextMenu">
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
CornerRadius="6" Padding="4" SnapsToDevicePixels="True">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 창 캡션 버튼 (최소화/최대화/닫기) ===== -->
<Style x:Key="CaptionBtn" TargetType="Button">
<Setter Property="Width" Value="46" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource B.CaptionFg}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Cursor" Value="Arrow" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
TextElement.FontFamily="{Binding FontFamily, RelativeSource={RelativeSource TemplatedParent}}"
TextElement.FontSize="{Binding FontSize, RelativeSource={RelativeSource TemplatedParent}}"
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource B.CaptionHover}" />
<Setter Property="Foreground" Value="{DynamicResource B.CaptionFgHover}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="CloseBtn" TargetType="Button" BasedOn="{StaticResource CaptionBtn}">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource B.CloseHover}" />
<Setter Property="Foreground" Value="White" />
</Trigger>
</Style.Triggers>
</Style>
<!-- ===== 컬럼 스플리터 (사이드바 너비 드래그 조절) ===== -->
<Style x:Key="ColSplitter" TargetType="GridSplitter">
<Setter Property="Width" Value="5" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="ResizeBehavior" Value="PreviousAndNext" />
<Setter Property="ResizeDirection" Value="Columns" />
<Setter Property="Background" Value="{DynamicResource B.Line}" />
<Setter Property="Cursor" Value="SizeWE" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridSplitter">
<Border x:Name="bd" Background="{TemplateBinding Background}" SnapsToDevicePixels="True" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 행 스플리터 (팔레트 ↔ 레이어 높이 조절) ===== -->
<Style x:Key="RowSplitter" TargetType="GridSplitter">
<Setter Property="Height" Value="6" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="ResizeBehavior" Value="PreviousAndNext" />
<Setter Property="ResizeDirection" Value="Rows" />
<Setter Property="Background" Value="{DynamicResource B.Line}" />
<Setter Property="Cursor" Value="SizeNS" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridSplitter">
<Border x:Name="bd" Background="{TemplateBinding Background}" SnapsToDevicePixels="True" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 스크롤바 (슬림 다크) — 모든 ScrollViewer/ListBox/캔버스에 일괄 적용 ===== -->
<Style x:Key="ScrollThumb" TargetType="Thumb">
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="IsTabStop" Value="False" />
<!-- 막대 최소 크기 — 아이템이 많아도 실처럼 가늘어지지 않게(트랙보다 길면 WPF가 트랙 길이로 클램프) -->
<Setter Property="MinHeight" Value="48" />
<Setter Property="MinWidth" Value="48" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="t" CornerRadius="4" Background="{DynamicResource B.ScrollThumb}" Margin="2" SnapsToDevicePixels="True" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="t" Property="Background" Value="{DynamicResource B.ScrollThumbHover}" /></Trigger>
<Trigger Property="IsDragging" Value="True"><Setter TargetName="t" Property="Background" Value="{DynamicResource B.ScrollThumbDrag}" /></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ScrollPageBtn" TargetType="RepeatButton">
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Focusable" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton"><Border Background="Transparent" /></ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 슬림 스크롤바 — 전 ScrollViewer/ListBox 공통(암시 적용) -->
<Style TargetType="ScrollBar">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Width" Value="11" />
<Setter Property="MinWidth" Value="11" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}" SnapsToDevicePixels="True">
<Track x:Name="PART_Track" Orientation="{TemplateBinding Orientation}" IsDirectionReversed="True">
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollPageBtn}" Command="ScrollBar.PageUpCommand" />
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollThumb}" />
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollPageBtn}" Command="ScrollBar.PageDownCommand" />
</Track.IncreaseRepeatButton>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Height" Value="11" />
<Setter Property="MinHeight" Value="11" />
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>
@@ -0,0 +1,46 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ============================================================
디자이너 다크 토큰 (Framer 다크 팔레트, framer.com 에디터 기준)
· DesignerTheme.xaml 의 스타일이 {DynamicResource B.*} 로 참조
· 라이트 대응본 = Tokens.Light.xaml (동일 키)
============================================================ -->
<!-- 색상 토큰 -->
<SolidColorBrush x:Key="B.Ink" Color="#EDEDED" />
<SolidColorBrush x:Key="B.Muted" Color="#969696" />
<SolidColorBrush x:Key="B.Line" Color="#272727" />
<SolidColorBrush x:Key="B.Line2" Color="#383838" />
<SolidColorBrush x:Key="B.Accent" Color="#0099FF" />
<SolidColorBrush x:Key="B.Accent2" Color="#2A2A2A" />
<SolidColorBrush x:Key="B.Sel" Color="#3C3C3C" /><!-- 선택/체크 강조 채움(입력칩 위에서 또렷) -->
<SolidColorBrush x:Key="B.Hover" Color="#242424" />
<SolidColorBrush x:Key="B.AppBg" Color="#0E0E0E" />
<SolidColorBrush x:Key="B.Panel" Color="#151515" />
<SolidColorBrush x:Key="B.PanelHeader" Color="#1C1C1C" />
<SolidColorBrush x:Key="B.Surface" Color="#1C1C1C" />
<SolidColorBrush x:Key="B.Titlebar" Color="#0E0E0E" />
<SolidColorBrush x:Key="B.Input" Color="#262626" />
<SolidColorBrush x:Key="B.InputBorder" Color="Transparent" /><!-- 다크는 입력 테두리 없음(Framer 식 채움만) -->
<SolidColorBrush x:Key="B.CanvasBg" Color="#2A2A2A" />
<SolidColorBrush x:Key="B.Chip" Color="#262626" />
<SolidColorBrush x:Key="B.Success" Color="#4CAF7D" />
<SolidColorBrush x:Key="B.Dark" Color="#0A0A0A" />
<!-- 트리거/템플릿에서 추출한 토큰(라이트 전환 위해) -->
<SolidColorBrush x:Key="B.BtnHover" Color="#34363C" />
<SolidColorBrush x:Key="B.BtnPressed" Color="#3C3F46" />
<SolidColorBrush x:Key="B.TabHover" Color="#2C2C2C" />
<SolidColorBrush x:Key="B.TabSel" Color="#353535" />
<SolidColorBrush x:Key="B.OnAccent" Color="#FFFFFF" /><!-- 강조 채움 위 글자/체크마크(양 테마 흰색) -->
<SolidColorBrush x:Key="B.ScrollThumb" Color="#454B54" />
<SolidColorBrush x:Key="B.ScrollThumbHover" Color="#646C79" />
<SolidColorBrush x:Key="B.ScrollThumbDrag" Color="#7E8693" />
<SolidColorBrush x:Key="B.CaptionFg" Color="#C7CDD4" />
<SolidColorBrush x:Key="B.CaptionFgHover" Color="#FFFFFF" />
<SolidColorBrush x:Key="B.CaptionHover" Color="#2E3138" />
<SolidColorBrush x:Key="B.CloseHover" Color="#E04A2B" />
<SolidColorBrush x:Key="B.SliderRing" Color="Transparent" /><!-- 다크는 테두리 없음(흰 썸 그대로) -->
</ResourceDictionary>
@@ -0,0 +1,46 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ============================================================
디자이너 라이트 토큰 — Tokens.Dark.xaml 과 동일 키, 라이트값
· Framer 라이트 에디터 기준: 중립 회색(쿨/블루톤 제거) + 강조는 Framer 블루 #0099FF
· 회색은 무채색(R=G=B)로 통일, 블루 틴트는 선택/활성(Accent2/Sel)에만
============================================================ -->
<!-- 색상 토큰 -->
<SolidColorBrush x:Key="B.Ink" Color="#222222" />
<SolidColorBrush x:Key="B.Muted" Color="#969696" />
<SolidColorBrush x:Key="B.Line" Color="#EAEAEA" />
<SolidColorBrush x:Key="B.Line2" Color="#D8D8D8" />
<SolidColorBrush x:Key="B.Accent" Color="#0099FF" />
<SolidColorBrush x:Key="B.Accent2" Color="#E4F2FF" /><!-- 은은한 활성 틴트(연블루) -->
<SolidColorBrush x:Key="B.Sel" Color="#DBEEFF" /><!-- 선택 채움(입력칩 위 또렷한 블루틴트) -->
<SolidColorBrush x:Key="B.Hover" Color="#F3F3F3" />
<SolidColorBrush x:Key="B.AppBg" Color="#F5F5F5" />
<SolidColorBrush x:Key="B.Panel" Color="#FFFFFF" />
<SolidColorBrush x:Key="B.PanelHeader" Color="#FAFAFA" />
<SolidColorBrush x:Key="B.Surface" Color="#FFFFFF" />
<SolidColorBrush x:Key="B.Titlebar" Color="#F7F7F7" /><!-- 순백 아님(패널과 미묘한 분리) -->
<SolidColorBrush x:Key="B.Input" Color="#FFFFFF" /><!-- 입력칩=흰색(라이트). 옅은 테두리 B.InputBorder 동반 -->
<SolidColorBrush x:Key="B.InputBorder" Color="#E3E3E3" /><!-- 흰 입력칩 가장자리 -->
<SolidColorBrush x:Key="B.CanvasBg" Color="#ECECEC" />
<SolidColorBrush x:Key="B.Chip" Color="#EFEFEF" />
<SolidColorBrush x:Key="B.Success" Color="#2E9E6B" />
<SolidColorBrush x:Key="B.Dark" Color="#1A1A1A" />
<!-- 트리거/템플릿 추출 토큰 -->
<SolidColorBrush x:Key="B.BtnHover" Color="#F3F3F3" />
<SolidColorBrush x:Key="B.BtnPressed" Color="#EAEAEA" />
<SolidColorBrush x:Key="B.TabHover" Color="#F0F0F0" />
<SolidColorBrush x:Key="B.TabSel" Color="#E8E8E8" />
<SolidColorBrush x:Key="B.OnAccent" Color="#FFFFFF" />
<SolidColorBrush x:Key="B.ScrollThumb" Color="#CDCDCD" />
<SolidColorBrush x:Key="B.ScrollThumbHover" Color="#B6B6B6" />
<SolidColorBrush x:Key="B.ScrollThumbDrag" Color="#A2A2A2" />
<SolidColorBrush x:Key="B.CaptionFg" Color="#444444" />
<SolidColorBrush x:Key="B.CaptionFgHover" Color="#1A1A1A" />
<SolidColorBrush x:Key="B.CaptionHover" Color="#E8E8E8" />
<SolidColorBrush x:Key="B.CloseHover" Color="#E04A2B" />
<SolidColorBrush x:Key="B.SliderRing" Color="#CFCFCF" /><!-- 라이트는 흰 썸 가장자리 링 -->
</ResourceDictionary>
@@ -0,0 +1,193 @@
using System.Windows;
using System.Windows.Media;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
namespace SheetMe.Designer.ViewModels.Controls;
/// <summary>
/// 캔버스 컨트롤 ViewModel 공통 — 모델(ControlElement)의 관찰 가능한 투영.
/// 모델이 진실이며 VM 은 버려도 되는 투영(Undo 복원 시 재생성)이다.
/// 좌표계: WinForms px = WPF DIP 1:1.
/// </summary>
public abstract class ControlViewModel : ViewModelBase
{
#region Member Fields
private bool isSelected;
private bool isHovered;
#endregion
#region Properties
/// <summary>원본 모델(저장 원본 — 참조 보관)</summary>
public ControlElement Model { get; }
/// <summary>부모 컨테이너 VM — 최상위(페이지 직속)면 null. X/Y 는 부모 기준 상대좌표</summary>
public ControlViewModel? Parent { get; set; }
/// <summary>컨트롤 이름(Id)</summary>
public string Id => Model.Id;
/// <summary>중립 타입명</summary>
public string Type => Model.Type;
/// <summary>부모 기준 X(px)</summary>
public double X
{
get => Model.Bounds.X;
set { Model.Bounds.X = value; OnPropertyChanged(nameof(X)); }
}
/// <summary>부모 기준 Y(px)</summary>
public double Y
{
get => Model.Bounds.Y;
set { Model.Bounds.Y = value; OnPropertyChanged(nameof(Y)); }
}
/// <summary>너비(px)</summary>
public double Width
{
get => Model.Bounds.W;
set { Model.Bounds.W = value; OnPropertyChanged(nameof(Width)); }
}
/// <summary>높이(px)</summary>
public double Height
{
get => Model.Bounds.H;
set { Model.Bounds.H = value; OnPropertyChanged(nameof(Height)); }
}
/// <summary>선택 상태(뷰 전용 — 저장 안 함)</summary>
public bool IsSelected
{
get => isSelected;
set => SetProperty(ref isSelected, value);
}
/// <summary>호버 상태(뷰 전용)</summary>
public bool IsHovered
{
get => isHovered;
set => SetProperty(ref isHovered, value);
}
/// <summary>정적 텍스트(Text Property)</summary>
public string Text => Model.Props.GetText("Text") ?? string.Empty;
/// <summary>유효 폰트 — 부모 체인 상속 반영(WinForms Font 상속 규약)</summary>
public LegacyFont EffectiveFont { get; private set; } = new();
/// <summary>WPF FontFamily</summary>
public FontFamily FontFamily => new(EffectiveFont.Family);
/// <summary>WPF FontSize(DIP) — pt × 96/72</summary>
public double FontSize => Math.Max(1, EffectiveFont.SizePt * 96.0 / 72.0);
/// <summary>굵게</summary>
public FontWeight FontWeight => EffectiveFont.Bold ? FontWeights.Bold : FontWeights.Normal;
/// <summary>기울임</summary>
public FontStyle FontStyle => EffectiveFont.Italic ? FontStyles.Italic : FontStyles.Normal;
/// <summary>글자색 — ForeColor 상속 반영</summary>
public Brush Foreground { get; private set; } = Brushes.Black;
/// <summary>배경색 — BackColor(명시 시)</summary>
public Brush Background { get; private set; } = Brushes.Transparent;
/// <summary>수평 텍스트 정렬 — TextAlign 계열 Property 해석</summary>
public HorizontalAlignment TextAlignment { get; private set; } = HorizontalAlignment.Left;
/// <summary>상속 기점 부모 폰트(재해석용 보관)</summary>
public LegacyFont ParentFont { get; private set; } = new();
/// <summary>상속 기점 부모 글자색(재해석용 보관)</summary>
public Brush ParentForeground { get; private set; } = Brushes.Black;
/// <summary>잠금 플래그(레이어 패널 토글)</summary>
public bool IsLockedFlag
{
get => Model.Locked;
set
{
Model.Locked = value;
OnPropertyChanged(nameof(IsLockedFlag));
}
}
/// <summary>숨김 플래그(레이어 패널 토글) — 캔버스 반투명 표시</summary>
public bool IsHiddenFlag
{
get => Model.Hidden;
set
{
Model.Hidden = value;
OnPropertyChanged(nameof(IsHiddenFlag));
OnPropertyChanged(nameof(DesignOpacity));
}
}
/// <summary>디자인 표시 불투명도 — 숨김이면 0.25</summary>
public double DesignOpacity => Model.Hidden ? 0.25 : 1.0;
#endregion
#region Constructors
protected ControlViewModel(ControlElement model)
{
Model = model;
}
#endregion
#region Methods
/// <summary>
/// 상속 컨텍스트(부모 폰트/글자색)를 적용하고 표시 속성을 재해석한다 — DocumentMapper 가 매핑 시 호출.
/// </summary>
public virtual void ResolveVisualContext(LegacyFont parentFont, Brush parentForeground)
{
ParentFont = parentFont;
ParentForeground = parentForeground;
var fontText = Model.Props.GetText("Font");
EffectiveFont = fontText is not null ? LegacyFormat.ParseFont(fontText) : parentFont;
var foreText = Model.Props.GetText("ForeColor");
Foreground = foreText is not null ? BrushFromLegacy(foreText) : parentForeground;
var backText = Model.Props.GetText("BackColor");
if (backText is not null)
{
Background = BrushFromLegacy(backText);
}
var align = Model.Props.GetText("TextAlign") ?? string.Empty;
TextAlignment = align.Contains("Center") ? HorizontalAlignment.Center
: align.Contains("Right") ? HorizontalAlignment.Right
: HorizontalAlignment.Left;
OnPropertyChanged(string.Empty);
}
/// <summary>레거시 색 문자열 → WPF Brush</summary>
protected static Brush BrushFromLegacy(string colorText)
{
var (a, r, g, b) = LegacyFormat.ParseColor(colorText);
var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
brush.Freeze();
return brush;
}
/// <summary>속성 변경 후 시각 재해석 — 보관된 부모 컨텍스트로 다시 해석</summary>
public void RefreshVisual() => ResolveVisualContext(ParentFont, ParentForeground);
/// <summary>전체 속성 변경 통지(개명 등 계산 속성 갱신)</summary>
public void NotifyAllChanged() => OnPropertyChanged(string.Empty);
/// <summary>텍스트 Property 조회 편의</summary>
protected string? Prop(string name) => Model.Props.GetText(name);
/// <summary>bool Property 조회 편의 — "True"/"False" 문자열</summary>
protected bool PropBool(string name, bool defaultValue = false)
=> bool.TryParse(Prop(name), out var value) ? value : defaultValue;
#endregion
}
@@ -0,0 +1,372 @@
using System.Collections.ObjectModel;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
namespace SheetMe.Designer.ViewModels.Controls;
// 타입별 컨트롤 ViewModel — DataTemplate 자동 선택(캔버스 렌더)의 키.
// 각 클래스는 얇은 표시 투영이므로 한 파일에 모아 관리한다.
/// <summary>라벨 (레거시 Label/MFormatLabel/MSequence)</summary>
public sealed class LabelViewModel : ControlViewModel
{
public LabelViewModel(ControlElement model) : base(model) { }
}
/// <summary>텍스트박스 (레거시 TextBox)</summary>
public sealed class TextBoxViewModel : ControlViewModel
{
/// <summary>여러 줄 여부</summary>
public bool Multiline => PropBool("Multiline");
/// <summary>테두리 표시 여부 — BorderStyle=None 이면 숨김</summary>
public bool ShowBorder => Prop("BorderStyle") != "None";
public TextBoxViewModel(ControlElement model) : base(model) { }
}
/// <summary>마스크 입력 (레거시 MaskedTextBox)</summary>
public sealed class MaskedTextBoxViewModel : ControlViewModel
{
/// <summary>입력 마스크</summary>
public string Mask => Prop("Mask") ?? string.Empty;
public MaskedTextBoxViewModel(ControlElement model) : base(model) { }
}
/// <summary>체크박스 (레거시 CheckBox/CheckLabel)</summary>
public sealed class CheckBoxViewModel : ControlViewModel
{
/// <summary>디자인 표시용 체크 상태</summary>
public bool IsChecked => Prop("Checked") == "True";
public CheckBoxViewModel(ControlElement model) : base(model) { }
}
/// <summary>라디오버튼 (레거시 RadioButton)</summary>
public sealed class RadioButtonViewModel : ControlViewModel
{
/// <summary>디자인 표시용 선택 상태</summary>
public bool IsChecked => Prop("Checked") == "True";
public RadioButtonViewModel(ControlElement model) : base(model) { }
}
/// <summary>콤보박스 (레거시 ComboBox)</summary>
public sealed class ComboBoxViewModel : ControlViewModel
{
/// <summary>선택지 목록 — Items Property(ItemsValue) 해석</summary>
public IReadOnlyList<string> Items => ItemsOf(Model, "Items");
/// <summary>디자인 표시 텍스트 — Text 또는 첫 항목</summary>
public string DisplayText => Text.Length > 0 ? Text : (Items.Count > 0 ? Items[0] : string.Empty);
public ComboBoxViewModel(ControlElement model) : base(model) { }
/// <summary>ItemsValue Property 를 문자열 목록으로</summary>
internal static IReadOnlyList<string> ItemsOf(ControlElement model, string propName)
{
if (model.Props.Get(propName) is LegacyPropValue.ItemsValue items)
{
return items.Items
.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty)
.ToList();
}
return Array.Empty<string>();
}
}
/// <summary>리스트박스 (레거시 ListBox)</summary>
public sealed class ListBoxViewModel : ControlViewModel
{
/// <summary>항목 목록</summary>
public IReadOnlyList<string> Items => ComboBoxViewModel.ItemsOf(Model, "Items");
public ListBoxViewModel(ControlElement model) : base(model) { }
}
/// <summary>체크리스트 (레거시 MCheckedListBox)</summary>
public sealed class CheckListViewModel : ControlViewModel
{
/// <summary>항목 목록</summary>
public IReadOnlyList<string> Items => ComboBoxViewModel.ItemsOf(Model, "Items");
public CheckListViewModel(ControlElement model) : base(model) { }
}
/// <summary>날짜선택 (레거시 DateTimePicker)</summary>
public sealed class DateTimePickerViewModel : ControlViewModel
{
/// <summary>디자인 표시 텍스트 — 포맷 힌트</summary>
public string DisplayText
{
get
{
var custom = Prop("CustomFormat");
if (!string.IsNullOrEmpty(custom))
{
return custom;
}
return Prop("Format") switch
{
"Time" => "오후 12:00:00",
"Short" => "2026-01-01",
_ => "2026년 1월 1일",
};
}
}
public DateTimePickerViewModel(ControlElement model) : base(model) { }
}
/// <summary>컨테이너 공통 — 자식 컬렉션 보유(Panel/GroupBox)</summary>
public abstract class ContainerViewModel : ControlViewModel
{
/// <summary>자식 컨트롤(그리기 순서 — 마지막이 최상위)</summary>
public ObservableCollection<ControlViewModel> Children { get; } = new();
protected ContainerViewModel(ControlElement model) : base(model) { }
/// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary>
public override void ResolveVisualContext(SheetMe.Core.Serialization.LegacyFont parentFont, System.Windows.Media.Brush parentForeground)
{
base.ResolveVisualContext(parentFont, parentForeground);
foreach (var child in Children)
{
child.ResolveVisualContext(EffectiveFont, Foreground);
}
}
}
/// <summary>패널 (레거시 Panel/Panel2/MLayerPanel/MExpandablePanel)</summary>
public sealed class PanelViewModel : ContainerViewModel
{
/// <summary>테두리 표시 여부 — BorderStyle 존재 시(None 제외)</summary>
public bool ShowBorder => Prop("BorderStyle") is not (null or "None");
public PanelViewModel(ControlElement model) : base(model) { }
}
/// <summary>그룹박스 (레거시 GroupBox)</summary>
public sealed class GroupBoxViewModel : ContainerViewModel
{
public GroupBoxViewModel(ControlElement model) : base(model) { }
}
/// <summary>선 (레거시 MLine)</summary>
public sealed class LineViewModel : ControlViewModel
{
/// <summary>선 색 — LineColor Property</summary>
public System.Windows.Media.Brush LineBrush
=> Prop("LineColor") is { } color ? BrushFromLegacy(color) : System.Windows.Media.Brushes.Black;
public LineViewModel(ControlElement model) : base(model) { }
}
/// <summary>이미지 (레거시 MPictureBox)</summary>
public sealed class PictureBoxViewModel : ControlViewModel
{
/// <summary>디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트</summary>
public string Hint => Prop("DataInterfaceTag") ?? "이미지";
public PictureBoxViewModel(ControlElement model) : base(model) { }
}
/// <summary>계산박스 (레거시 MCalcBox)</summary>
public sealed class CalcBoxViewModel : ControlViewModel
{
/// <summary>수식 원문 — Formula Property</summary>
public string Formula => Prop("Formula") ?? string.Empty;
public CalcBoxViewModel(ControlElement model) : base(model) { }
}
/// <summary>버튼 (레거시 MButton : WinForms Button — 클릭 시 DataActionTag 검색폼 호출)</summary>
public sealed class ButtonViewModel : ControlViewModel
{
/// <summary>액션 태그 표시(None 이면 빈 값)</summary>
public string ActionHint
{
get
{
var tag = Prop("DataActionTag");
return tag is null or "None" ? string.Empty : tag;
}
}
public ButtonViewModel(ControlElement model) : base(model) { }
}
/// <summary>
/// 데이터소스 (레거시 MDataTable — 34×34 DB 아이콘, 런타임 비가시 쿼리 소스).
/// 디자인 화면에서만 배지로 보이고 인쇄/런타임에는 그려지지 않는다.
/// </summary>
public sealed class DataTableViewModel : ControlViewModel
{
/// <summary>쿼리 요약(툴팁/배지) — 첫 60자</summary>
public string QuerySummary
{
get
{
var query = (Prop("Query") ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim();
return query.Length == 0
? "쿼리 미설정"
: query.Length > 60 ? query[..60] + "…" : query;
}
}
public DataTableViewModel(ControlElement model) : base(model) { }
}
/// <summary>Spread 격자선 1개(로컬 좌표)</summary>
public sealed class SpreadLine
{
public double X1 { get; init; }
public double Y1 { get; init; }
public double X2 { get; init; }
public double Y2 { get; init; }
}
/// <summary>Spread 셀 표시 1개(로컬 좌표)</summary>
public sealed class SpreadCellView
{
public double X { get; init; }
public double Y { get; init; }
public double W { get; init; }
public double H { get; init; }
public string Text { get; init; } = string.Empty;
}
/// <summary>
/// 표 (레거시 Spread — FarPoint). 디자인은 E_SpdMst 별도 저장 —
/// DB 서식 열기 시 파싱된 격자(GridInfo)를 주입받아 렌더한다(읽기 전용, 편집은 레거시 디자이너 사용).
/// </summary>
public sealed class SpreadViewModel : ControlViewModel
{
private SheetMe.Core.Serialization.SpreadGridInfo? gridInfo;
/// <summary>파싱된 격자 정보 — 주입 시 지오메트리 재계산</summary>
public SheetMe.Core.Serialization.SpreadGridInfo? GridInfo
{
get => gridInfo;
set
{
gridInfo = value;
RecalcGeometry();
}
}
/// <summary>격자 정보 보유 여부(없으면 자리표시 렌더)</summary>
public bool HasGrid => gridInfo is not null;
/// <summary>내부 격자선(로컬 좌표, 외곽 제외)</summary>
public System.Collections.ObjectModel.ObservableCollection<SpreadLine> GridLines { get; } = new();
/// <summary>셀 텍스트(로컬 좌표, 스팬 반영)</summary>
public System.Collections.ObjectModel.ObservableCollection<SpreadCellView> CellViews { get; } = new();
public SpreadViewModel(ControlElement model) : base(model)
{
PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Width) or nameof(Height))
{
RecalcGeometry();
}
};
}
/// <summary>격자/셀 지오메트리 재계산 — 컨트롤 크기 내 클리핑(500×500 전체가 아닌 보이는 만큼만)</summary>
private void RecalcGeometry()
{
GridLines.Clear();
CellViews.Clear();
OnPropertyChanged(nameof(HasGrid));
if (gridInfo is null)
{
return;
}
var width = Math.Max(1, Width);
var height = Math.Max(1, Height);
// 세로선(열 경계) — 누적 너비가 컨트롤 폭 안인 것만
var x = 0.0;
var colEdges = new List<double> { 0 };
for (var c = 0; x < width && c < 512; c++)
{
x += gridInfo.ColWidthOf(c);
if (x >= width)
{
break;
}
colEdges.Add(x);
GridLines.Add(new SpreadLine { X1 = x, Y1 = 0, X2 = x, Y2 = height });
}
// 가로선(행 경계)
var y = 0.0;
var rowEdges = new List<double> { 0 };
for (var r = 0; y < height && r < 512; r++)
{
y += gridInfo.RowHeightOf(r);
if (y >= height)
{
break;
}
rowEdges.Add(y);
GridLines.Add(new SpreadLine { X1 = 0, Y1 = y, X2 = width, Y2 = y });
}
// 셀 텍스트 — 스팬 반영, 보이는 영역만
foreach (var cell in gridInfo.Cells)
{
var span = gridInfo.Spans.FirstOrDefault(s => s.Row == cell.Row && s.Col == cell.Col);
var cellX = SumCols(0, cell.Col);
var cellY = SumRows(0, cell.Row);
if (cellX >= width || cellY >= height)
{
continue;
}
var cellW = SumCols(cell.Col, cell.Col + (span?.ColSpan ?? 1));
var cellH = SumRows(cell.Row, cell.Row + (span?.RowSpan ?? 1));
CellViews.Add(new SpreadCellView
{
X = cellX,
Y = cellY,
W = Math.Min(cellW, width - cellX),
H = Math.Min(cellH, height - cellY),
Text = cell.Text,
});
}
double SumCols(int from, int to)
{
var sum = 0.0;
for (var c = from; c < to; c++)
{
sum += gridInfo!.ColWidthOf(c);
}
return sum;
}
double SumRows(int from, int to)
{
var sum = 0.0;
for (var r = from; r < to; r++)
{
sum += gridInfo!.RowHeightOf(r);
}
return sum;
}
}
}
/// <summary>미지원 컨트롤 자리표시 — 원본 타입명 표시, 이동/리사이즈만 허용</summary>
public sealed class PlaceholderViewModel : ControlViewModel
{
/// <summary>원본 레거시 클래스명</summary>
public string LegacyClassName
=> Model.LegacyAqn is { } aqn ? LegacyTypeCatalog.ShortClassName(aqn) : "Unknown";
public PlaceholderViewModel(ControlElement model) : base(model) { }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,376 @@
using System.Collections.ObjectModel;
using System.Globalization;
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.ViewModels.Inspector;
/// <summary>
/// 속성 인스펙터 — ControlRegistry 스키마 기반 행 구성.
/// 선택 '집합' 변경 시에만 재구성(드래그 프레임 제외), 다중선택은 값 동일성 병합("여러 값").
/// 커밋 규약: 값 변경 시 Undo 스냅샷 1회 → 전체 선택 대상 적용 → 시각 재해석.
/// </summary>
public sealed class InspectorViewModel : ViewModelBase
{
#region Member Fields
private const string StringItemAqn =
"System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089";
private readonly DesignerViewModel designer;
private readonly List<(PropertyRowViewModel Row, RowBinding Binding)> boundsRows = new();
private bool showAdvanced;
/// <summary>공통/글꼴 섹션이 이미 다루는 키 — 고급 목록에서 제외</summary>
private static readonly HashSet<string> HandledKeys = new(StringComparer.Ordinal)
{
"Location", "Size", "LocationOnBase", "Name", "Font",
};
#endregion
#region Properties
/// <summary>행 목록(섹션 헤더 포함)</summary>
public ObservableCollection<PropertyRowViewModel> Rows { get; } = new();
/// <summary>선택 요약 텍스트</summary>
public string Summary
=> designer.Selection.Items.Count switch
{
0 => "선택 없음",
1 => $"{designer.Selection.Primary!.Type} — {designer.Selection.Primary!.Id}",
var n => $"{n}개 선택",
};
#endregion
#region Constructors
public InspectorViewModel(DesignerViewModel designer)
{
this.designer = designer;
designer.Selection.SetChanged += Rebuild;
designer.Selection.Changed += RefreshBoundsRows; // 드래그/리사이즈 중 X/Y/W/H 실시간 갱신
}
#endregion
#region Methods
/// <summary>경계(X/Y/W/H) 행 값만 갱신 — 이동/리사이즈 프레임(재구성 없이 가볍게)</summary>
private void RefreshBoundsRows()
{
var items = designer.Selection.Items;
if (items.Count == 0)
{
return;
}
foreach (var (row, binding) in boundsRows)
{
var values = items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
row.Initialize(values.Count == 1 ? values[0] : null, isMixed: values.Count > 1);
}
}
/// <summary>행 재구성 — 선택 집합 변경 시</summary>
public void Rebuild()
{
Rows.Clear();
boundsRows.Clear();
OnPropertyChanged(nameof(Summary));
var items = designer.Selection.Items;
if (items.Count == 0)
{
return;
}
// 공통: 이름/위치/크기
Rows.Add(new SectionRowViewModel("공통"));
if (items.Count == 1)
{
AddRow(new TextRowViewModel("이름"), new RowBinding
{
Get = vm => vm.Id,
Set = (vm, value) => RenameControl(vm, value),
AffectsVisual = false,
});
}
AddBoundsRow("X", vm => vm.X, (vm, v) => vm.X = v);
AddBoundsRow("Y", vm => vm.Y, (vm, v) => vm.Y = v);
AddBoundsRow("너비", vm => vm.Width, (vm, v) => vm.Width = Math.Max(1, v));
AddBoundsRow("높이", vm => vm.Height, (vm, v) => vm.Height = Math.Max(1, v));
// 폰트(공통) — Font Property(상속 시 빈 값)
Rows.Add(new SectionRowViewModel("글꼴"));
AddFontRow("글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v);
AddFontRow("크기(pt)", f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture),
(f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0 ? size : f.SizePt);
AddFontToggleRow("굵게", f => f.Bold, (f, v) => f.Bold = v);
AddFontToggleRow("밑줄", f => f.Underline, (f, v) => f.Underline = v);
// 타입 전용 — 전체 선택이 같은 타입일 때만
var type = items[0].Type;
var curatedKeys = new HashSet<string>(HandledKeys, StringComparer.Ordinal);
if (items.All(i => i.Type == type) && ControlRegistry.Find(type) is { } descriptor && descriptor.Properties.Count > 0)
{
Rows.Add(new SectionRowViewModel(descriptor.DisplayName));
foreach (var def in descriptor.Properties)
{
AddDefRow(def);
curatedKeys.Add(def.Key);
}
}
// 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가)
if (items.Count == 1)
{
BuildAdvancedRows(items[0], curatedKeys);
}
}
/// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary>
private void BuildAdvancedRows(ControlViewModel target, HashSet<string> curatedKeys)
{
var advancedKeys = target.Model.Props.Keys
.Where(k => !curatedKeys.Contains(k))
.ToList();
if (!showAdvanced)
{
Rows.Add(new ToggleAdvancedRowViewModel($"전체 속성 표시 ▾ ({advancedKeys.Count}개)", () =>
{
showAdvanced = true;
Rebuild();
}));
return;
}
Rows.Add(new SectionRowViewModel("전체 속성 (고급 — 레거시 원문)"));
Rows.Add(new ToggleAdvancedRowViewModel("전체 속성 숨기기 ▴", () =>
{
showAdvanced = false;
Rebuild();
}));
foreach (var key in advancedKeys)
{
var value = target.Model.Props.Get(key);
switch (value)
{
case LegacyPropValue.TextValue or LegacyPropValue.NullValue or null:
AddRow(new TextRowViewModel(key), new RowBinding
{
Get = vm => vm.Model.Props.GetText(key) ?? string.Empty,
Set = (vm, newValue) => vm.Model.Props.SetText(key, newValue),
});
break;
case LegacyPropValue.ItemsValue items:
AddRow(new MultilineTextRowViewModel(key + " (목록)"), new RowBinding
{
Get = vm => vm.Model.Props.Get(key) is LegacyPropValue.ItemsValue iv
? string.Join("\n", iv.Items.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty))
: string.Empty,
Set = (vm, newValue) =>
{
var itemsValue = new LegacyPropValue.ItemsValue();
var aqn = items.Items.FirstOrDefault()?.Aqn ?? StringItemAqn;
foreach (var line in newValue.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
itemsValue.Items.Add(new LegacyItem { Aqn = aqn, Value = new LegacyPropValue.TextValue(line) });
}
vm.Model.Props.Set(key, itemsValue);
},
});
break;
case LegacyPropValue.NestedValue nested:
Rows.Add(new ReadOnlyRowViewModel(key, $"(중첩 속성 {nested.Children.Count}개 — 보존됨)"));
break;
case LegacyPropValue.BinaryValue:
Rows.Add(new ReadOnlyRowViewModel(key, "(바이너리 — 원문 보존됨)"));
break;
case LegacyPropValue.ReferenceValue reference:
Rows.Add(new ReadOnlyRowViewModel(key, $"(참조: {reference.Name})"));
break;
}
}
// 속성 추가 — 레거시 컨트롤의 임의 속성 지정(오타 주의: 로더가 모르는 키는 경고 처리)
Rows.Add(new AddPropertyRowViewModel(key =>
{
if (target.Model.Props.Contains(key))
{
System.Windows.MessageBox.Show($"이미 존재하는 속성입니다: {key}", "속성 추가");
return;
}
designer.Undo.Snapshot();
target.Model.Props.SetText(key, string.Empty);
Rebuild();
}));
}
private void AddDefRow(PropertyDef def)
{
var binding = def.Editor == PropEditorKind.StringList
? new RowBinding
{
Get = vm => vm.Model.Props.Get(def.Key) is LegacyPropValue.ItemsValue items
? string.Join("\n", items.Items.Select(i => i.Value is LegacyPropValue.TextValue t ? t.Value : string.Empty))
: string.Empty,
Set = (vm, value) =>
{
var itemsValue = new LegacyPropValue.ItemsValue();
foreach (var line in value.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
itemsValue.Items.Add(new LegacyItem { Aqn = StringItemAqn, Value = new LegacyPropValue.TextValue(line) });
}
vm.Model.Props.Set(def.Key, itemsValue);
},
}
: new RowBinding
{
Get = vm => vm.Model.Props.GetText(def.Key),
Set = (vm, value) =>
{
if (value.Length == 0)
{
vm.Model.Props.Remove(def.Key);
}
else
{
vm.Model.Props.SetText(def.Key, value);
}
},
};
PropertyRowViewModel row = def.Editor switch
{
PropEditorKind.MultilineText or PropEditorKind.StringList => new MultilineTextRowViewModel(def.Label),
PropEditorKind.Number => new NumberRowViewModel(def.Label),
PropEditorKind.Toggle => new ToggleRowViewModel(def.Label),
PropEditorKind.Choice => new ChoiceRowViewModel(def.Label, def.Choices ?? Array.Empty<string>()),
PropEditorKind.Color => new ColorRowViewModel(def.Label),
PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label,
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags),
PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label,
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags),
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label),
_ => new TextRowViewModel(def.Label),
};
AddRow(row, binding);
}
private void AddBoundsRow(string label, Func<ControlViewModel, double> get, Action<ControlViewModel, double> set)
{
var row = new NumberRowViewModel(label);
var binding = new RowBinding
{
Get = vm => Math.Round(get(vm)).ToString(CultureInfo.InvariantCulture),
Set = (vm, value) =>
{
if (double.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var number))
{
set(vm, Math.Round(number));
}
},
AffectsVisual = false,
};
AddRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged());
boundsRows.Add((row, binding));
}
private void AddFontRow(string label, Func<LegacyFont, string> get, Action<LegacyFont, string> set)
{
AddRow(new TextRowViewModel(label), new RowBinding
{
Get = vm => get(vm.EffectiveFont),
Set = (vm, value) =>
{
var font = CurrentFontOf(vm);
set(font, value);
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
},
});
}
private void AddFontToggleRow(string label, Func<LegacyFont, bool> get, Action<LegacyFont, bool> set)
{
AddRow(new ToggleRowViewModel(label), new RowBinding
{
Get = vm => get(vm.EffectiveFont) ? "True" : "False",
Set = (vm, value) =>
{
var font = CurrentFontOf(vm);
set(font, value == "True");
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
},
});
}
/// <summary>편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화</summary>
private static LegacyFont CurrentFontOf(ControlViewModel vm)
{
var explicitFont = vm.Model.Props.GetText("Font");
if (explicitFont is not null)
{
return LegacyFormat.ParseFont(explicitFont);
}
var inherited = vm.EffectiveFont;
return new LegacyFont
{
Family = inherited.Family,
SizePt = inherited.SizePt,
Bold = inherited.Bold,
Italic = inherited.Italic,
Underline = inherited.Underline,
Strikeout = inherited.Strikeout,
};
}
private void AddRow(PropertyRowViewModel row, RowBinding binding, Action? afterCommit = null)
{
var items = designer.Selection.Items;
var values = items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
row.Initialize(values.Count == 1 ? values[0] : null, isMixed: values.Count > 1);
row.Commit = value =>
{
var targets = designer.Selection.Items.ToList();
if (targets.Count == 0)
{
return;
}
designer.Undo.Snapshot();
foreach (var target in targets)
{
binding.Set(target, value);
if (binding.AffectsVisual)
{
target.RefreshVisual();
}
target.NotifyAllChanged();
}
afterCommit?.Invoke();
};
Rows.Add(row);
}
private void RenameControl(ControlViewModel vm, string newId)
{
var trimmed = newId.Trim();
if (trimmed.Length == 0 || trimmed == vm.Id)
{
return;
}
var used = IdGenerator.CollectUsed(designer.Document);
used.Remove(vm.Id);
if (used.Contains(trimmed))
{
System.Windows.MessageBox.Show($"이미 사용 중인 이름입니다: {trimmed}", "이름 변경");
return;
}
vm.Model.Id = trimmed;
OnPropertyChanged(nameof(Summary));
}
#endregion
}
@@ -0,0 +1,302 @@
using System.Windows.Media;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.ViewModels.Inspector;
/// <summary>
/// 인스펙터 행 ViewModel — 라벨 + 문자열 정규화 값. 커밋 시(값 변경 시에만) 소유자 콜백으로
/// Undo 스냅샷 → 전체 선택 대상 적용 → 시각 재해석이 수행된다.
/// </summary>
public abstract class PropertyRowViewModel : ViewModelBase
{
#region Member Fields
private string valueText = string.Empty;
private bool building;
#endregion
#region Properties
/// <summary>행 라벨(한글)</summary>
public string Label { get; }
/// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary>
public bool IsMixed { get; private set; }
/// <summary>정규화 문자열 값 — 파생 편집기가 형 변환</summary>
public string ValueText
{
get => valueText;
set
{
if (!SetProperty(ref valueText, value) || building)
{
return;
}
IsMixed = false;
OnPropertyChanged(nameof(IsMixed));
Commit?.Invoke(value);
OnValueApplied();
}
}
/// <summary>커밋 콜백 — InspectorViewModel 이 배선(스냅샷+적용)</summary>
public Action<string>? Commit { get; set; }
#endregion
#region Constructors
protected PropertyRowViewModel(string label)
{
Label = label;
}
#endregion
#region Methods
/// <summary>초기값 세팅(커밋 미발생)</summary>
public void Initialize(string? value, bool isMixed = false)
{
building = true;
valueText = value ?? string.Empty;
IsMixed = isMixed;
OnPropertyChanged(nameof(ValueText));
OnPropertyChanged(nameof(IsMixed));
building = false;
}
/// <summary>값 적용 후 파생 갱신 지점</summary>
protected virtual void OnValueApplied() { }
#endregion
}
/// <summary>구분 헤더 행</summary>
public sealed class SectionRowViewModel : PropertyRowViewModel
{
public SectionRowViewModel(string label) : base(label) { }
}
/// <summary>한 줄 문자열 행</summary>
public sealed class TextRowViewModel : PropertyRowViewModel
{
public TextRowViewModel(string label) : base(label) { }
}
/// <summary>여러 줄 문자열 행(Text/수식/항목 목록)</summary>
public sealed class MultilineTextRowViewModel : PropertyRowViewModel
{
public MultilineTextRowViewModel(string label) : base(label) { }
}
/// <summary>숫자 행 — 문자열 바인딩, 커밋 시 숫자 검증은 소유자에서</summary>
public sealed class NumberRowViewModel : PropertyRowViewModel
{
public NumberRowViewModel(string label) : base(label) { }
}
/// <summary>토글(참/거짓) 행</summary>
public sealed class ToggleRowViewModel : PropertyRowViewModel
{
/// <summary>체크 상태 — "True"/"False" 문자열과 동기</summary>
public bool IsOn
{
get => ValueText == "True";
set => ValueText = value ? "True" : "False";
}
public ToggleRowViewModel(string label) : base(label) { }
protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn));
}
/// <summary>선택지 행</summary>
public sealed class ChoiceRowViewModel : PropertyRowViewModel
{
/// <summary>선택지 목록</summary>
public string[] Choices { get; }
public ChoiceRowViewModel(string label, string[] choices) : base(label)
{
Choices = choices;
}
}
/// <summary>
/// 태그 피커 행 — 값 표시 + 찾아보기 버튼(검색 대화상자).
/// 목록에 없는 사이트 커스텀 태그는 텍스트 직접 입력도 허용.
/// </summary>
public sealed class TagPickerRowViewModel : PropertyRowViewModel
{
/// <summary>피커 선택지(레거시 카탈로그)</summary>
public IReadOnlyList<string> Choices { get; }
/// <summary>피커 대화상자 제목</summary>
public string PickerTitle { get; }
/// <summary>찾아보기 — 검색 대화상자 열기</summary>
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
public TagPickerRowViewModel(string label, string pickerTitle, IReadOnlyList<string> choices) : base(label)
{
PickerTitle = pickerTitle;
Choices = choices;
BrowseCommand = new M.Framework.WPF.Command((sender, e) => OnBrowse());
}
private void OnBrowse()
{
var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText)
{
Owner = System.Windows.Application.Current.MainWindow,
};
if (dialog.ShowDialog() == true)
{
ValueText = dialog.SelectedTag ?? string.Empty;
}
}
}
/// <summary>SQL 쿼리 행 — 요약 표시 + 전용 편집기 창(치환 변수 삽입)</summary>
public sealed class QueryRowViewModel : PropertyRowViewModel
{
/// <summary>요약 텍스트(한 줄)</summary>
public string Summary
{
get
{
var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim();
return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine;
}
}
/// <summary>전용 편집기 열기</summary>
public M.Framework.WPF.ICustomCommand? EditCommand { get; set; }
public QueryRowViewModel(string label) : base(label)
{
EditCommand = new M.Framework.WPF.Command((sender, e) => OnEdit());
}
protected override void OnValueApplied() => OnPropertyChanged(nameof(Summary));
private void OnEdit()
{
var dialog = new Views.QueryEditorWindow(Label, ValueText)
{
Owner = System.Windows.Application.Current.MainWindow,
};
if (dialog.ShowDialog() == true)
{
ValueText = dialog.QueryText;
}
}
}
/// <summary>색 행 — 레거시 invariant 문자열("R, G, B"/명명색) + 미리보기 스와치</summary>
public sealed class ColorRowViewModel : PropertyRowViewModel
{
/// <summary>미리보기 브러시</summary>
public Brush Preview
{
get
{
if (ValueText.Length == 0)
{
return Brushes.Transparent;
}
var (a, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(ValueText);
var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
brush.Freeze();
return brush;
}
}
/// <summary>색상 피커 열기 — 확정 시 레거시 invariant 형식("R, G, B")으로 반영</summary>
public M.Framework.WPF.ICustomCommand? PickCommand { get; set; }
public ColorRowViewModel(string label) : base(label)
{
PickCommand = new M.Framework.WPF.Command((sender, e) => OnPick());
}
protected override void OnValueApplied() => OnPropertyChanged(nameof(Preview));
private void OnPick()
{
var initialHex = (string?)null;
if (ValueText.Length > 0)
{
var (_, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(ValueText);
initialHex = $"#{r:X2}{g:X2}{b:X2}";
}
var picked = Views.ColorPickerWindow.Pick(System.Windows.Application.Current.MainWindow, initialHex);
if (picked is null)
{
return;
}
var color = (Color)ColorConverter.ConvertFromString(picked);
ValueText = Core.Serialization.LegacyFormat.FormatColor(255, color.R, color.G, color.B);
}
}
/// <summary>읽기 전용 행 — 중첩/바이너리/참조 등 raw 편집 불가 값 표시</summary>
public sealed class ReadOnlyRowViewModel : PropertyRowViewModel
{
public ReadOnlyRowViewModel(string label, string display) : base(label)
{
Initialize(display);
}
}
/// <summary>전체 속성(고급) 섹션 토글 행 — 표시/숨김 버튼</summary>
public sealed class ToggleAdvancedRowViewModel : PropertyRowViewModel
{
/// <summary>버튼 표시 텍스트</summary>
public string ButtonText { get; }
/// <summary>토글 실행</summary>
public M.Framework.WPF.ICustomCommand? ToggleCommand { get; set; }
public ToggleAdvancedRowViewModel(string buttonText, Action toggle) : base(string.Empty)
{
ButtonText = buttonText;
ToggleCommand = new M.Framework.WPF.Command((sender, e) => toggle());
}
}
/// <summary>속성 추가 행 — 키 입력 후 빈 속성 생성(고급)</summary>
public sealed class AddPropertyRowViewModel : PropertyRowViewModel
{
private string keyText = string.Empty;
/// <summary>추가할 속성 키(레거시 Property 이름)</summary>
public string KeyText
{
get => keyText;
set => SetProperty(ref keyText, value);
}
/// <summary>추가 실행</summary>
public M.Framework.WPF.ICustomCommand? AddCommand { get; set; }
public AddPropertyRowViewModel(Action<string> add) : base(string.Empty)
{
AddCommand = new M.Framework.WPF.Command((sender, e) =>
{
var key = KeyText.Trim();
if (key.Length > 0)
{
add(key);
}
});
}
}
/// <summary>인스펙터 행 컨텍스트 — 대상 컨트롤 집합과 접근자</summary>
public sealed class RowBinding
{
/// <summary>값 읽기</summary>
public required Func<ControlViewModel, string?> Get { get; init; }
/// <summary>값 쓰기</summary>
public required Action<ControlViewModel, string> Set { get; init; }
/// <summary>커밋 후 시각 재해석 필요 여부</summary>
public bool AffectsVisual { get; init; } = true;
}
@@ -0,0 +1,690 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Windows;
using M.Framework.WPF;
using SheetMe.Core.Catalog;
using SheetMe.Data.Stores;
using SheetMe.Designer.DataBusiness;
using SheetMe.Designer.Services;
namespace SheetMe.Designer.ViewModels;
/// <summary>
/// 메인 셸 ViewModel — 멀티 문서 탭(여러 기록지 동시 편집), 상시 서식 목록 패널,
/// 파일/DB 열기·저장, 도구(상용구/폰트). 컨트롤 클립보드는 문서 간 공유(서식 간 복사/붙여넣기).
/// </summary>
internal sealed class MainViewModel : ViewModelBase
{
#region Member Fields
private readonly FormDesignDataBusiness dataBusiness = new();
private readonly DialogService dialogService = new();
private DesignerViewModel? currentDesigner;
private string title = "SheetMe 서식생성기";
private string statusText = "준비";
private string sheetSearchKeyword = string.Empty;
private bool isSheetListLoading;
private int selectedLeftTabIndex;
private bool isHandTool;
private const string XmlFilter = "서식 XML (*.xml)|*.xml|모든 파일 (*.*)|*.*";
#endregion
#region Properties
/// <summary>창 제목</summary>
public string Title
{
get => title;
set => SetProperty(ref title, value);
}
/// <summary>상태바 텍스트</summary>
public string StatusText
{
get => statusText;
set => SetProperty(ref statusText, value);
}
/// <summary>열린 문서(탭) 목록</summary>
public ObservableCollection<DesignerViewModel> OpenDesigners { get; } = new();
/// <summary>활성 문서(선택 탭)</summary>
public DesignerViewModel? CurrentDesigner
{
get => currentDesigner;
set
{
if (SetProperty(ref currentDesigner, value))
{
Title = value is null
? "SheetMe 서식생성기"
: $"SheetMe 서식생성기 — {value.DisplayName}";
}
}
}
/// <summary>팔레트 컨트롤 목록(레지스트리)</summary>
public IReadOnlyList<ControlDescriptor> PaletteItems => ControlRegistry.All;
/// <summary>서식 목록(상시 패널) — DB E_ShtMst</summary>
public ObservableCollection<SheetSummary> SheetList { get; } = new();
/// <summary>서식 목록 검색어</summary>
public string SheetSearchKeyword
{
get => sheetSearchKeyword;
set => SetProperty(ref sheetSearchKeyword, value);
}
/// <summary>서식 목록 로딩 중</summary>
public bool IsSheetListLoading
{
get => isSheetListLoading;
set => SetProperty(ref isSheetListLoading, value);
}
/// <summary>DB 사용 가능 여부(서식 목록 패널 안내용)</summary>
public bool CanUseDb => dataBusiness.CanUseDb;
/// <summary>좌측 패널 탭(0=서식 목록, 1=레이어, 2=도구 상자) — 서식 열면 레이어로 자동 전환([200] 관행)</summary>
public int SelectedLeftTabIndex
{
get => selectedLeftTabIndex;
set => SetProperty(ref selectedLeftTabIndex, value);
}
/// <summary>손(팬) 도구 활성 — 캔버스 드래그로 화면 이동(false=선택 도구, [200] 플로팅 바 관행)</summary>
public bool IsHandTool
{
get => isHandTool;
set => SetProperty(ref isHandTool, value);
}
#endregion
#region Constructors
public MainViewModel()
{
LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync());
NewFileCommand = new Command((sender, e) => OnNewFile());
OpenFileCommand = new Command((sender, e) => OnOpenFile());
SaveFileCommand = new Command((sender, e) => OnSaveFile(saveAs: false));
SaveAsFileCommand = new Command((sender, e) => OnSaveFile(saveAs: true));
OpenFromDbCommand = new Command((sender, e) => OnOpenFromDb());
SaveToDbCommand = new Command((sender, e) => OnSaveToDb());
ExportJsonCommand = new Command((sender, e) => OnExportJson());
ImportJsonCommand = new Command((sender, e) => OnImportJson());
PreviewCommand = new Command((sender, e) => OnPreview());
PrintCommand = new Command((sender, e) => OnPrint());
RecordWordCommand = new Command((sender, e) => OnRecordWords());
FontManagerCommand = new Command((sender, e) => OnFontManager());
SheetHistoryCommand = new Command((sender, e) => OnSheetHistory());
ZoomInCommand = new Command((sender, e) => CurrentDesigner?.ZoomIn());
ZoomOutCommand = new Command((sender, e) => CurrentDesigner?.ZoomOut());
ZoomResetCommand = new Command((sender, e) => CurrentDesigner?.ZoomReset());
ExitCommand = new Command((sender, e) => Application.Current.Shutdown());
SearchSheetsCommand = new Command(async (sender, e) => await LoadSheetListAsync());
OpenSheetCommand = new Command((object param) => OnOpenSheetFromList(param as SheetSummary));
CloseDocumentCommand = new Command((object param) => OnCloseDocument(param as DesignerViewModel));
}
#endregion
#region Commands
/// <summary>창 로드</summary>
public ICustomCommand? LoadedCommand { get; set; }
/// <summary>새 서식</summary>
public ICustomCommand? NewFileCommand { get; set; }
/// <summary>서식 XML 열기</summary>
public ICustomCommand? OpenFileCommand { get; set; }
/// <summary>저장</summary>
public ICustomCommand? SaveFileCommand { get; set; }
/// <summary>다른 이름으로 저장</summary>
public ICustomCommand? SaveAsFileCommand { get; set; }
/// <summary>DB에서 서식 열기(검색 대화상자)</summary>
public ICustomCommand? OpenFromDbCommand { get; set; }
/// <summary>DB에 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — SaveMode 게이트</summary>
public ICustomCommand? SaveToDbCommand { get; set; }
/// <summary>JSON 내보내기</summary>
public ICustomCommand? ExportJsonCommand { get; set; }
/// <summary>JSON 가져오기</summary>
public ICustomCommand? ImportJsonCommand { get; set; }
/// <summary>미리보기</summary>
public ICustomCommand? PreviewCommand { get; set; }
/// <summary>인쇄</summary>
public ICustomCommand? PrintCommand { get; set; }
/// <summary>상용구 관리</summary>
public ICustomCommand? RecordWordCommand { get; set; }
/// <summary>폰트 일괄 변경</summary>
public ICustomCommand? FontManagerCommand { get; set; }
/// <summary>서식 수정이력(버전 열람/복원)</summary>
public ICustomCommand? SheetHistoryCommand { get; set; }
/// <summary>줌 확대</summary>
public ICustomCommand? ZoomInCommand { get; set; }
/// <summary>줌 축소</summary>
public ICustomCommand? ZoomOutCommand { get; set; }
/// <summary>줌 100%</summary>
public ICustomCommand? ZoomResetCommand { get; set; }
/// <summary>종료</summary>
public ICustomCommand? ExitCommand { get; set; }
/// <summary>서식 목록 검색</summary>
public ICustomCommand? SearchSheetsCommand { get; set; }
/// <summary>서식 목록에서 열기(더블클릭)</summary>
public ICustomCommand? OpenSheetCommand { get; set; }
/// <summary>문서 탭 닫기</summary>
public ICustomCommand? CloseDocumentCommand { get; set; }
#endregion
#region Methods -
/// <summary>문서를 탭으로 추가하고 활성화 — 좌측 패널은 레이어 탭으로 전환</summary>
private void AttachDocument(DesignerViewModel designer)
{
OpenDesigners.Add(designer);
CurrentDesigner = designer;
SelectedLeftTabIndex = 1;
}
private void OnCloseDocument(DesignerViewModel? designer)
{
designer ??= CurrentDesigner;
if (designer is null)
{
return;
}
if (designer.Undo.CanUndo
&& MessageBox.Show($"'{designer.DisplayName}' 문서에 저장하지 않은 변경이 있을 수 있습니다.\n닫을까요?",
"문서 닫기", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes)
{
return;
}
var index = OpenDesigners.IndexOf(designer);
OpenDesigners.Remove(designer);
if (CurrentDesigner == designer)
{
CurrentDesigner = OpenDesigners.Count > 0
? OpenDesigners[Math.Clamp(index, 0, OpenDesigners.Count - 1)]
: null;
}
}
/// <summary>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
private DesignerViewModel? FindOpenDbDocument(string shtCod)
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod);
#endregion
#region Methods - /
private async Task OnLoadedAsync()
{
OnNewFile();
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
await LoadSheetListAsync();
}
private void OnNewFile()
{
try
{
AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
StatusText = "새 서식 (720×856)";
}
catch (Exception ex)
{
MessageBox.Show(ex.ToString());
}
}
private void OnOpenFile()
{
try
{
var path = dialogService.ShowOpenFile(XmlFilter, "서식 XML 열기");
if (path is null)
{
return;
}
var document = dataBusiness.OpenXmlFile(path);
AttachDocument(new DesignerViewModel(document) { FilePath = path });
StatusText = $"파일 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
ShowReadWarnings(document.Meta.ReadWarnings, "서식 열기");
}
catch (Exception ex)
{
MessageBox.Show($"서식을 여는 중 오류가 발생했습니다.\n\n{ex}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnSaveFile(bool saveAs)
{
try
{
if (CurrentDesigner is null)
{
return;
}
var path = CurrentDesigner.FilePath;
if (saveAs || path is null)
{
path = dialogService.ShowSaveFile(XmlFilter, "서식 XML 저장",
CurrentDesigner.Document.FormId + ".xml");
if (path is null)
{
return;
}
}
dataBusiness.SaveXmlFile(CurrentDesigner.Document, path);
CurrentDesigner.FilePath = path;
CurrentDesigner.NotifyDisplayNameChanged();
Title = $"SheetMe 서식생성기 — {CurrentDesigner.DisplayName}";
StatusText = $"저장됨: {path}";
}
catch (Exception ex)
{
MessageBox.Show($"저장 중 오류가 발생했습니다.\n\n{ex}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnOpenFromDb()
{
try
{
if (!EnsureDb())
{
return;
}
var dialog = new Views.SheetOpenDialogView(keyword => dataBusiness.ListSheets(keyword))
{
Owner = Application.Current.MainWindow,
};
if (dialog.ShowDialog() != true || dialog.SelectedSheet is null)
{
return;
}
OpenDbSheet(dialog.SelectedSheet.ShtCod);
}
catch (Exception ex)
{
MessageBox.Show($"DB에서 서식을 여는 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화</summary>
private void OpenDbSheet(string shtCod)
{
var existing = FindOpenDbDocument(shtCod);
if (existing is not null)
{
CurrentDesigner = existing;
StatusText = $"이미 열린 서식: {shtCod}";
return;
}
var document = dataBusiness.OpenFromDb(shtCod);
if (document is null)
{
MessageBox.Show("활성 디자인을 찾지 못했습니다.", "DB 열기",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
AttachDocument(new DesignerViewModel(document, dataBusiness.LoadSpreadGrids(shtCod)) { IsFromDb = true });
StatusText = $"DB 로드: {document.FormId} (SdgKey {document.Meta.SourceSdgKey}) · " +
$"페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
ShowReadWarnings(document.Meta.ReadWarnings, "DB 열기");
}
private void OnSaveToDb()
{
try
{
if (CurrentDesigner is null)
{
return;
}
if (!dataBusiness.CanSaveToDb)
{
MessageBox.Show("DB 저장이 비활성화되어 있습니다.\nappsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정한 뒤 사용하세요.\n(운영 레거시 테이블에 기록되므로 명시적 활성화가 필요합니다)",
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var document = CurrentDesigner.Document;
// 미등록 서식이면 신규 등록 다이얼로그
if (!dataBusiness.SheetExists(document.FormId))
{
var register = new Views.RegisterSheetDialogView(
document.FormId == "NewSheet" ? string.Empty : document.FormId,
document.Title == "새 서식" ? string.Empty : document.Title)
{
Owner = Application.Current.MainWindow,
};
if (register.ShowDialog() != true)
{
return;
}
dataBusiness.RegisterSheet(register.SheetCode, register.SheetName, register.ClassCode);
document.FormId = register.SheetCode;
document.Title = register.SheetName;
}
var confirm = MessageBox.Show(
$"서식 [{document.FormId}] {document.Title} 을(를) DB(E_SdgMst/E_SctMst)에 저장할까요?\n\n" +
"기존 활성 디자인은 이력(SdgDelYon='Y')으로 보존되고 새 버전이 생성됩니다.\n" +
"(제자리 갱신 서식(ShtCneYon='Y')은 기존 버전이 갱신됩니다)",
"DB 저장 확인", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes)
{
return;
}
var sdgKey = dataBusiness.SaveToDb(document);
CurrentDesigner.IsFromDb = true;
CurrentDesigner.NotifyDisplayNameChanged();
StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}";
MessageBox.Show($"저장되었습니다. (SdgKey {sdgKey})\n레거시 뷰어/디자이너에서 열어 확인하세요.", "DB 저장",
MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show($"DB 저장 중 오류가 발생했습니다. (트랜잭션 롤백됨)\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnExportJson()
{
try
{
if (CurrentDesigner is null)
{
return;
}
var path = dialogService.ShowSaveFile("서식 JSON (*.json)|*.json", "JSON 내보내기",
CurrentDesigner.Document.FormId + ".json");
if (path is null)
{
return;
}
var json = new Core.Serialization.FormJsonSerializer().Write(CurrentDesigner.Document);
File.WriteAllText(path, json, System.Text.Encoding.UTF8);
StatusText = $"JSON 내보내기 완료: {path}";
}
catch (Exception ex)
{
MessageBox.Show($"JSON 내보내기 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnImportJson()
{
try
{
var path = dialogService.ShowOpenFile("서식 JSON (*.json)|*.json", "JSON 가져오기");
if (path is null)
{
return;
}
var document = new Core.Serialization.FormJsonSerializer().Read(File.ReadAllText(path));
if (document.FormId.Length == 0)
{
document.FormId = Path.GetFileNameWithoutExtension(path);
}
AttachDocument(new DesignerViewModel(document));
StatusText = $"JSON 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}";
}
catch (Exception ex)
{
MessageBox.Show($"JSON 가져오기 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
#endregion
#region Methods -
/// <summary>서식 목록 로드(비동기) — DB 미접속이면 건너뜀</summary>
private async Task LoadSheetListAsync()
{
if (!dataBusiness.CanUseDb || IsSheetListLoading)
{
return;
}
try
{
IsSheetListLoading = true;
var keyword = SheetSearchKeyword;
var sheets = await Task.Run(() => dataBusiness.ListSheets(keyword));
SheetList.Clear();
foreach (var sheet in sheets)
{
SheetList.Add(sheet);
}
StatusText = $"서식 목록 {sheets.Count}건";
}
catch (Exception ex)
{
MessageBox.Show($"서식 목록 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "서식 목록",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
IsSheetListLoading = false;
}
}
/// <summary>서식 목록에서 열기 — 뷰 더블클릭 핸들러가 위임 호출</summary>
public void OpenSheetFromList(SheetSummary? sheet) => OnOpenSheetFromList(sheet);
private void OnOpenSheetFromList(SheetSummary? sheet)
{
try
{
if (sheet is null)
{
return;
}
if (!sheet.HasDesign)
{
MessageBox.Show("선택한 서식에는 저장된 디자인이 없습니다.", "서식 열기",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
OpenDbSheet(sheet.ShtCod);
}
catch (Exception ex)
{
MessageBox.Show($"서식을 여는 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
#endregion
#region Methods -
private void OnPreview()
{
if (CurrentDesigner is null)
{
return;
}
new Views.PreviewWindow(CurrentDesigner)
{
Owner = Application.Current.MainWindow,
}.Show();
}
private void OnPrint()
{
try
{
if (CurrentDesigner is null)
{
return;
}
Services.PrintService.Print(CurrentDesigner, CurrentDesigner.Document.Title.Length > 0
? CurrentDesigner.Document.Title
: CurrentDesigner.Document.FormId);
}
catch (Exception ex)
{
MessageBox.Show($"인쇄 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnFontManager()
{
if (CurrentDesigner is null)
{
return;
}
new Views.FontManagerDialogView(CurrentDesigner)
{
Owner = Application.Current.MainWindow,
}.ShowDialog();
}
private void OnSheetHistory()
{
try
{
if (CurrentDesigner is null || !EnsureDb())
{
return;
}
var document = CurrentDesigner.Document;
if (document.FormId.Length == 0 || document.FormId == "NewSheet")
{
MessageBox.Show("수정이력은 DB에 저장된 서식에서 사용할 수 있습니다.", "서식 수정이력",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var versions = dataBusiness.ListVersions(document.FormId);
if (versions.Count == 0)
{
MessageBox.Show("저장된 버전이 없습니다.", "서식 수정이력",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var dialog = new Views.SheetHistoryDialogView(document.FormId, document.Title, versions)
{
Owner = Application.Current.MainWindow,
};
if (dialog.ShowDialog() != true || dialog.SelectedSdgKey is not { } sdgKey)
{
return;
}
// 이미 열람 중인 동일 버전 탭이면 활성화
var existing = OpenDesigners.FirstOrDefault(d => d.HistorySdgKey == sdgKey
&& d.Document.FormId == document.FormId);
if (existing is not null)
{
CurrentDesigner = existing;
return;
}
var versionDocument = dataBusiness.OpenFromDbVersion(document.FormId, sdgKey);
if (versionDocument is null)
{
MessageBox.Show("해당 버전을 불러오지 못했습니다.", "서식 수정이력",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
AttachDocument(new DesignerViewModel(versionDocument, dataBusiness.LoadSpreadGrids(document.FormId))
{
IsFromDb = true,
HistorySdgKey = sdgKey,
});
StatusText = $"이력 열람: {document.FormId} SdgKey {sdgKey} — 'DB에 저장' 시 이 내용이 새 활성 버전이 됩니다";
}
catch (Exception ex)
{
MessageBox.Show($"수정이력 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnRecordWords()
{
try
{
if (CurrentDesigner is null)
{
return;
}
if (!EnsureDb())
{
return;
}
var document = CurrentDesigner.Document;
if (document.FormId.Length == 0 || document.FormId == "NewSheet")
{
MessageBox.Show("상용구는 서식 코드 단위로 저장됩니다.\n먼저 DB에 저장(서식 등록)한 뒤 사용하세요.",
"상용구 관리", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
new Views.RecordWordDialogView(dataBusiness.RecordWords(), document.FormId, document.Title)
{
Owner = Application.Current.MainWindow,
}.ShowDialog();
}
catch (Exception ex)
{
MessageBox.Show($"상용구 관리 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
#endregion
#region Methods - Private
private bool EnsureDb()
{
if (dataBusiness.CanUseDb)
{
return true;
}
MessageBox.Show("DB 접속 문자열(ConnectionStrings:His)이 설정되지 않았습니다.\nappsettings.json 을 확인하세요.",
"DB 연결", MessageBoxButton.OK, MessageBoxImage.Information);
return false;
}
private static void ShowReadWarnings(List<string> warnings, string caption)
{
if (warnings.Count == 0)
{
return;
}
var summary = string.Join("\n", warnings.Take(20));
var more = warnings.Count > 20 ? $"\n... 외 {warnings.Count - 20}건" : string.Empty;
MessageBox.Show($"읽기 경고 {warnings.Count}건 (미지원 컨트롤은 자리표시로 보존됩니다):\n\n{summary}{more}",
caption, MessageBoxButton.OK, MessageBoxImage.Information);
}
private static int CountControls(List<Core.Models.ControlElement> controls)
=> controls.Sum(c => 1 + CountControls(c.Children));
#endregion
}
@@ -0,0 +1,76 @@
using System.Collections.ObjectModel;
using System.Windows.Media;
using SheetMe.Core.Models;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.ViewModels;
/// <summary>페이지 ViewModel — 용지 1장의 크기·배경·컨트롤(그리기 순서) 투영.</summary>
public sealed class PageViewModel : ViewModelBase
{
#region Member Fields
private double offsetY;
#endregion
#region Properties
/// <summary>페이지 모델</summary>
public FormPage Model { get; }
/// <summary>페이지 번호(0-base)</summary>
public int Index { get; }
/// <summary>페이지 컨트롤(그리기 순서 — 마지막이 최상위)</summary>
public ObservableCollection<ControlViewModel> Controls { get; } = new();
/// <summary>용지 너비(DIP)</summary>
public double WidthDip => Model.Width;
/// <summary>용지 높이(DIP)</summary>
public double HeightDip => Model.Height;
/// <summary>월드 좌표 세로 오프셋 — DesignerViewModel 이 페이지 스택 계산</summary>
public double OffsetY
{
get => offsetY;
set => SetProperty(ref offsetY, value);
}
/// <summary>용지 배경 브러시 — 루트 BackColor(기본 White)</summary>
public Brush PaperBrush
{
get
{
var backColor = Model.Root.Props.GetText("BackColor");
if (backColor is null)
{
return Brushes.White;
}
var (a, r, g, b) = Core.Serialization.LegacyFormat.ParseColor(backColor);
var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b));
brush.Freeze();
return brush;
}
}
#endregion
#region Constructors
public PageViewModel(FormPage model, int index)
{
Model = model;
Index = index;
}
#endregion
#region Methods
/// <summary>용지 크기 변경 통지</summary>
public void NotifySizeChanged()
{
OnPropertyChanged(nameof(WidthDip));
OnPropertyChanged(nameof(HeightDip));
OnPropertyChanged(nameof(SizeText));
}
/// <summary>페이지 패널 표시 텍스트</summary>
public string SizeText => $"{WidthDip:0}×{HeightDip:0}";
#endregion
}
@@ -0,0 +1,187 @@
using System.Collections.ObjectModel;
using System.Windows;
namespace SheetMe.Designer.ViewModels;
/// <summary>오버레이 가이드선 1개 — 월드 좌표 수직/수평선</summary>
public sealed class GuideLineInfo
{
/// <summary>수직선 여부(false=수평선)</summary>
public bool IsVertical { get; init; }
/// <summary>선 위치(월드 X 또는 Y)</summary>
public double Position { get; init; }
}
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
public sealed class TabBadgeInfo
{
/// <summary>배지 X(월드)</summary>
public double X { get; init; }
/// <summary>배지 Y(월드)</summary>
public double Y { get; init; }
/// <summary>표시 텍스트(순번 또는 "")</summary>
public string Text { get; init; } = string.Empty;
/// <summary>순번 지정 여부(지정=파랑, 미지정=회색)</summary>
public bool IsAssigned { get; init; }
}
/// <summary>선택 핸들 1개 — 월드 좌표(중심점)</summary>
public sealed class HandleInfo
{
/// <summary>핸들 인덱스(0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)</summary>
public int Index { get; init; }
/// <summary>핸들 좌상단 X(월드)</summary>
public double X { get; init; }
/// <summary>핸들 좌상단 Y(월드)</summary>
public double Y { get; init; }
}
/// <summary>
/// 선택/마퀴/가이드 오버레이 표시 상태 — 전부 월드 좌표.
/// 오버레이 뷰는 이 상태를 그리기만 한다(입력 처리 없음).
/// </summary>
public sealed class SelectionOverlayViewModel : ViewModelBase
{
#region Member Fields
/// <summary>핸들 한 변 크기(논리 px)</summary>
public const double HandleSize = 8;
private bool hasSelection;
private double selX;
private double selY;
private double selW;
private double selH;
private bool showHandles;
private bool hasMarquee;
private double marX;
private double marY;
private double marW;
private double marH;
#endregion
#region Properties
/// <summary>선택 박스 표시 여부</summary>
public bool HasSelection { get => hasSelection; set => SetProperty(ref hasSelection, value); }
/// <summary>선택 박스 X(월드)</summary>
public double SelX { get => selX; set => SetProperty(ref selX, value); }
/// <summary>선택 박스 Y(월드)</summary>
public double SelY { get => selY; set => SetProperty(ref selY, value); }
/// <summary>선택 박스 너비</summary>
public double SelW { get => selW; set => SetProperty(ref selW, value); }
/// <summary>선택 박스 높이</summary>
public double SelH { get => selH; set => SetProperty(ref selH, value); }
/// <summary>리사이즈 핸들 표시 여부(잠금 선택 시 숨김)</summary>
public bool ShowHandles { get => showHandles; set => SetProperty(ref showHandles, value); }
/// <summary>핸들 8개(월드 좌표)</summary>
public ObservableCollection<HandleInfo> Handles { get; } = new();
/// <summary>마퀴 표시 여부</summary>
public bool HasMarquee { get => hasMarquee; set => SetProperty(ref hasMarquee, value); }
/// <summary>마퀴 X(월드)</summary>
public double MarX { get => marX; set => SetProperty(ref marX, value); }
/// <summary>마퀴 Y(월드)</summary>
public double MarY { get => marY; set => SetProperty(ref marY, value); }
/// <summary>마퀴 너비</summary>
public double MarW { get => marW; set => SetProperty(ref marW, value); }
/// <summary>마퀴 높이</summary>
public double MarH { get => marH; set => SetProperty(ref marH, value); }
/// <summary>정렬 가이드선 목록</summary>
public ObservableCollection<GuideLineInfo> Guides { get; } = new();
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
#endregion
#region Methods
/// <summary>선택 박스/핸들 갱신 — bbox 는 월드 좌표</summary>
public void UpdateSelection(Rect? bbox, bool handlesVisible)
{
if (bbox is null || bbox.Value.IsEmpty)
{
HasSelection = false;
ShowHandles = false;
Handles.Clear();
return;
}
var rect = bbox.Value;
HasSelection = true;
SelX = rect.X;
SelY = rect.Y;
SelW = rect.Width;
SelH = rect.Height;
ShowHandles = handlesVisible;
Handles.Clear();
if (!handlesVisible)
{
return;
}
var half = HandleSize / 2;
var positions = HandlePositions(rect);
for (var i = 0; i < positions.Length; i++)
{
Handles.Add(new HandleInfo { Index = i, X = positions[i].X - half, Y = positions[i].Y - half });
}
}
/// <summary>핸들 중심 좌표 8개(0=NW 시계방향)</summary>
public static Point[] HandlePositions(Rect rect) => new[]
{
new Point(rect.Left, rect.Top),
new Point(rect.Left + rect.Width / 2, rect.Top),
new Point(rect.Right, rect.Top),
new Point(rect.Right, rect.Top + rect.Height / 2),
new Point(rect.Right, rect.Bottom),
new Point(rect.Left + rect.Width / 2, rect.Bottom),
new Point(rect.Left, rect.Bottom),
new Point(rect.Left, rect.Top + rect.Height / 2),
};
/// <summary>마퀴 갱신 — null 이면 숨김</summary>
public void UpdateMarquee(Rect? rect)
{
if (rect is null)
{
HasMarquee = false;
return;
}
HasMarquee = true;
MarX = rect.Value.X;
MarY = rect.Value.Y;
MarW = rect.Value.Width;
MarH = rect.Value.Height;
}
/// <summary>가이드선 교체</summary>
public void SetGuides(double? guideX, double? guideY)
{
Guides.Clear();
if (guideX is not null)
{
Guides.Add(new GuideLineInfo { IsVertical = true, Position = guideX.Value });
}
if (guideY is not null)
{
Guides.Add(new GuideLineInfo { IsVertical = false, Position = guideY.Value });
}
}
#endregion
}
@@ -0,0 +1,19 @@
using M.Framework.WPF;
namespace SheetMe.Designer.ViewModels;
/// <summary>프로젝트 공통 ViewModel 베이스 — BindableBase + 비동기 정리 지원.</summary>
public abstract class ViewModelBase : BindableBase, IAsyncDisposable
{
#region Dispose
/// <summary>비동기 정리</summary>
public async ValueTask DisposeAsync()
{
await DisposeAsyncCore();
GC.SuppressFinalize(this);
}
/// <summary>파생 클래스 정리 지점</summary>
protected virtual ValueTask DisposeAsyncCore() => ValueTask.CompletedTask;
#endregion
}
@@ -0,0 +1,338 @@
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
namespace SheetMe.Designer.Views;
/// <summary>
/// Figma 식 색상 피커([200]SheetMe ColorPickerWindow 이식) — 채도/명도(SV) 사각형 + 색상(Hue) 슬라이더
/// + HEX·RGB 입력 + 프리셋/최근색. 정적 <see cref="Pick"/> 호출: 확정 시 "#RRGGBB" 반환, 취소 시 null.
/// 셸 색은 앱 테마 토큰(B.*) — SV/Hue/스와치는 색상 원본 그대로.
/// </summary>
public sealed class ColorPickerWindow : Window
{
#region Member Fields
private static readonly List<string> recent = new();
private double hue360; // H 0..360
private double sat; // S 0..1
private double val; // V 0..1
private bool syncing; // 텍스트박스 ↔ 상태 갱신 재진입 방지
private readonly Canvas svArea = new() { Width = 248, Height = 168, ClipToBounds = true, Cursor = Cursors.Cross };
private readonly Rectangle svHue = new() { Width = 248, Height = 168 };
private readonly Ellipse svThumb = new() { Width = 14, Height = 14, Stroke = Brushes.White, StrokeThickness = 2, IsHitTestVisible = false };
private readonly Canvas hueArea = new() { Width = 248, Height = 16, ClipToBounds = true, Cursor = Cursors.Cross };
private readonly Border hueThumb = new() { Width = 6, Height = 20, BorderBrush = Brushes.White, BorderThickness = new Thickness(2), CornerRadius = new CornerRadius(2), IsHitTestVisible = false };
private readonly Border preview = new() { Width = 40, Height = 40, CornerRadius = new CornerRadius(6), BorderThickness = new Thickness(1) };
private readonly TextBox hexBox = new() { Width = 92, VerticalContentAlignment = VerticalAlignment.Center };
private readonly TextBox rBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
private readonly TextBox gBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
private readonly TextBox bBox = new() { Width = 46, VerticalContentAlignment = VerticalAlignment.Center };
#endregion
#region Properties
/// <summary>확정된 "#RRGGBB"(취소 시 null)</summary>
public string? Result { get; private set; }
#endregion
#region Methods
/// <summary>색상 선택 대화상자 — 확정 시 "#RRGGBB", 취소 시 null. initialHex 로 초기색 지정.</summary>
public static string? Pick(Window? owner, string? initialHex)
{
var window = new ColorPickerWindow(initialHex);
if (owner is not null)
{
window.Owner = owner;
}
return window.ShowDialog() == true ? window.Result : null;
}
private ColorPickerWindow(string? initialHex)
{
Title = "색상 선택";
Width = 300;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.NoResize;
ShowInTaskbar = false;
FontSize = 12.5;
var line = FindBrush("B.Line");
var muted = FindBrush("B.Muted");
preview.BorderBrush = line;
var initial = ParseHex(initialHex) ?? Color.FromRgb(0x2F, 0x6D, 0xF0);
(hue360, sat, val) = RgbToHsv(initial.R, initial.G, initial.B);
var root = new StackPanel { Margin = new Thickness(14) };
// ── SV 사각형(흰색→hue 가로 + 투명→검정 세로) ──
svArea.Children.Add(svHue);
svArea.Children.Add(new Rectangle
{
Width = 248, Height = 168,
Fill = new LinearGradientBrush(Color.FromArgb(255, 255, 255, 255), Color.FromArgb(0, 255, 255, 255), new Point(0, 0), new Point(1, 0)),
});
svArea.Children.Add(new Rectangle
{
Width = 248, Height = 168,
Fill = new LinearGradientBrush(Color.FromArgb(0, 0, 0, 0), Color.FromArgb(255, 0, 0, 0), new Point(0, 0), new Point(0, 1)),
});
svArea.Children.Add(svThumb);
svArea.MouseLeftButtonDown += (_, e) => { svArea.CaptureMouse(); UpdateSvFrom(e.GetPosition(svArea)); };
svArea.MouseMove += (_, e) => { if (e.LeftButton == MouseButtonState.Pressed && svArea.IsMouseCaptured) UpdateSvFrom(e.GetPosition(svArea)); };
svArea.MouseLeftButtonUp += (_, _) => svArea.ReleaseMouseCapture();
root.Children.Add(new Border { Child = svArea, CornerRadius = new CornerRadius(6), ClipToBounds = true, Margin = new Thickness(0, 0, 0, 10) });
// ── Hue 슬라이더(무지개) ──
hueArea.Children.Add(new Rectangle
{
Width = 248, Height = 16,
Fill = new LinearGradientBrush(new GradientStopCollection
{
new(Color.FromRgb(255, 0, 0), 0), new(Color.FromRgb(255, 255, 0), 1 / 6.0), new(Color.FromRgb(0, 255, 0), 2 / 6.0),
new(Color.FromRgb(0, 255, 255), 3 / 6.0), new(Color.FromRgb(0, 0, 255), 4 / 6.0), new(Color.FromRgb(255, 0, 255), 5 / 6.0), new(Color.FromRgb(255, 0, 0), 1),
}, new Point(0, 0), new Point(1, 0)),
RadiusX = 8, RadiusY = 8,
});
hueArea.Children.Add(hueThumb);
Canvas.SetTop(hueThumb, -2);
hueArea.MouseLeftButtonDown += (_, e) => { hueArea.CaptureMouse(); UpdateHueFrom(e.GetPosition(hueArea)); };
hueArea.MouseMove += (_, e) => { if (e.LeftButton == MouseButtonState.Pressed && hueArea.IsMouseCaptured) UpdateHueFrom(e.GetPosition(hueArea)); };
hueArea.MouseLeftButtonUp += (_, _) => hueArea.ReleaseMouseCapture();
root.Children.Add(new Border { Child = hueArea, Margin = new Thickness(0, 0, 0, 12) });
// ── 미리보기 + HEX / RGB ──
var inputs = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 12) };
inputs.Children.Add(preview);
var fields = new StackPanel { Margin = new Thickness(12, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
var hexRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
hexRow.Children.Add(new TextBlock { Text = "HEX", Width = 30, VerticalAlignment = VerticalAlignment.Center, Foreground = muted });
hexRow.Children.Add(hexBox);
fields.Children.Add(hexRow);
var rgbRow = new StackPanel { Orientation = Orientation.Horizontal };
rgbRow.Children.Add(new TextBlock { Text = "RGB", Width = 30, VerticalAlignment = VerticalAlignment.Center, Foreground = muted });
rgbRow.Children.Add(rBox);
rgbRow.Children.Add(new Border { Width = 4 });
rgbRow.Children.Add(gBox);
rgbRow.Children.Add(new Border { Width = 4 });
rgbRow.Children.Add(bBox);
fields.Children.Add(rgbRow);
inputs.Children.Add(fields);
root.Children.Add(inputs);
hexBox.LostKeyboardFocus += (_, _) => CommitHex();
hexBox.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitHex(); };
foreach (var box in new[] { rBox, gBox, bBox })
{
box.LostKeyboardFocus += (_, _) => CommitRgb();
box.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitRgb(); };
}
// ── 프리셋 + 최근색 ──
root.Children.Add(SwatchGrid("프리셋", new[]
{
"#000000", "#374151", "#6B727B", "#C7CDD4", "#FFFFFF", "#C0392B", "#E67E22", "#F1C40F",
"#1F9D55", "#16A085", "#2F6DF0", "#2980B9", "#8E44AD", "#EC4899", "#7F1D1D", "#FDE68A",
}));
if (recent.Count > 0)
{
root.Children.Add(SwatchGrid("최근 사용", recent.ToArray()));
}
// ── 버튼 ──
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
var ok = new Button { Content = "적용", MinWidth = 64, Margin = new Thickness(0, 0, 8, 0), IsDefault = true };
try { ok.Style = (Style)FindResource("Primary"); } catch { /* 테마 미로드 시 기본 */ }
ok.Click += (_, _) => { Result = CurrentHex(); AddRecent(Result); DialogResult = true; };
var cancel = new Button { Content = "취소", MinWidth = 64, IsCancel = true };
cancel.Click += (_, _) => DialogResult = false;
buttons.Children.Add(ok);
buttons.Children.Add(cancel);
root.Children.Add(buttons);
Content = root;
RefreshAll();
}
private FrameworkElement SwatchGrid(string title, string[] hexes)
{
var section = new StackPanel { Margin = new Thickness(0, 2, 0, 0) };
section.Children.Add(new TextBlock { Text = title, FontSize = 11, Foreground = FindBrush("B.Muted"), Margin = new Thickness(0, 0, 0, 4) });
var wrap = new WrapPanel();
foreach (var hex in hexes)
{
var swatch = new Button
{
Width = 22, Height = 22, Margin = new Thickness(0, 0, 4, 4), Padding = new Thickness(0),
ToolTip = hex, BorderBrush = FindBrush("B.Line2"),
Background = new SolidColorBrush(ParseHex(hex) ?? Colors.White),
};
var captured = hex;
swatch.Click += (_, _) =>
{
if (ParseHex(captured) is not { } color)
{
return;
}
(hue360, sat, val) = RgbToHsv(color.R, color.G, color.B);
RefreshAll();
};
wrap.Children.Add(swatch);
}
section.Children.Add(wrap);
return section;
}
private static Brush FindBrush(string key)
=> Application.Current.TryFindResource(key) as Brush ?? Brushes.Gray;
// ── 상호작용 → 상태 ──
private void UpdateSvFrom(Point point)
{
sat = Math.Clamp(point.X / svArea.Width, 0, 1);
val = 1 - Math.Clamp(point.Y / svArea.Height, 0, 1);
RefreshAll();
}
private void UpdateHueFrom(Point point)
{
hue360 = Math.Clamp(point.X / hueArea.Width, 0, 1) * 360;
RefreshAll();
}
private void CommitHex()
{
if (syncing)
{
return;
}
if (ParseHex(hexBox.Text) is not { } color)
{
RefreshAll();
return;
}
(hue360, sat, val) = RgbToHsv(color.R, color.G, color.B);
RefreshAll();
}
private void CommitRgb()
{
if (syncing)
{
return;
}
if (byte.TryParse(rBox.Text, out var r) && byte.TryParse(gBox.Text, out var g) && byte.TryParse(bBox.Text, out var b))
{
(hue360, sat, val) = RgbToHsv(r, g, b);
}
RefreshAll();
}
// ── 상태 → 화면 ──
private void RefreshAll()
{
var (hueR, hueG, hueB) = HsvToRgb(hue360, 1, 1);
svHue.Fill = new SolidColorBrush(Color.FromRgb(hueR, hueG, hueB));
Canvas.SetLeft(svThumb, sat * svArea.Width - 7);
Canvas.SetTop(svThumb, (1 - val) * svArea.Height - 7);
var (r, g, b) = HsvToRgb(hue360, sat, val);
svThumb.Fill = new SolidColorBrush(Color.FromRgb(r, g, b));
Canvas.SetLeft(hueThumb, hue360 / 360 * hueArea.Width - 3);
preview.Background = new SolidColorBrush(Color.FromRgb(r, g, b));
syncing = true;
hexBox.Text = CurrentHex();
rBox.Text = r.ToString();
gBox.Text = g.ToString();
bBox.Text = b.ToString();
syncing = false;
}
private string CurrentHex()
{
var (r, g, b) = HsvToRgb(hue360, sat, val);
return $"#{r:X2}{g:X2}{b:X2}";
}
private static void AddRecent(string? hex)
{
if (string.IsNullOrEmpty(hex))
{
return;
}
recent.Remove(hex);
recent.Insert(0, hex);
while (recent.Count > 16)
{
recent.RemoveAt(recent.Count - 1);
}
}
// ── 색 변환 ──
private static Color? ParseHex(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
var s = text.Trim().TrimStart('#');
if (s.Length == 3)
{
s = $"{s[0]}{s[0]}{s[1]}{s[1]}{s[2]}{s[2]}";
}
if (s.Length != 6)
{
return null;
}
return int.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
? Color.FromRgb((byte)((value >> 16) & 0xFF), (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF))
: null;
}
private static (double H, double S, double V) RgbToHsv(byte r, byte g, byte b)
{
double rd = r / 255.0, gd = g / 255.0, bd = b / 255.0;
double max = Math.Max(rd, Math.Max(gd, bd)), min = Math.Min(rd, Math.Min(gd, bd));
double d = max - min, h = 0;
if (d != 0)
{
if (max == rd)
{
h = 60 * (((gd - bd) / d) % 6);
}
else if (max == gd)
{
h = 60 * (((bd - rd) / d) + 2);
}
else
{
h = 60 * (((rd - gd) / d) + 4);
}
}
if (h < 0)
{
h += 360;
}
return (h, max == 0 ? 0 : d / max, max);
}
private static (byte R, byte G, byte B) HsvToRgb(double h, double s, double v)
{
double c = v * s, x = c * (1 - Math.Abs((h / 60 % 2) - 1)), m = v - c;
double r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
return ((byte)Math.Round((r + m) * 255), (byte)Math.Round((g + m) * 255), (byte)Math.Round((b + m) * 255));
}
#endregion
}
@@ -0,0 +1,73 @@
<UserControl x:Class="SheetMe.Designer.Views.DesignerCanvasView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
xmlns:v="clr-namespace:SheetMe.Designer.Views"
xmlns:bh="clr-namespace:SheetMe.Designer.Behaviors"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance vm:DesignerViewModel}"
Focusable="True" FocusVisualStyle="{x:Null}"
PreviewKeyDown="OnCanvasPreviewKeyDown" PreviewKeyUp="OnCanvasPreviewKeyUp">
<!-- 키보드 단축키(텍스트 입력 보호 포함) -->
<b:Interaction.Behaviors>
<bh:CanvasKeyboardBehavior/>
</b:Interaction.Behaviors>
<!-- 디자인 캔버스: 스크롤 + 줌(LayoutTransform) 월드. 입력은 World 1곳에서 수신 -->
<ScrollViewer x:Name="Scroll"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Background="{DynamicResource B.CanvasBg}"
Focusable="False"
PreviewMouseWheel="OnPreviewMouseWheel"
PreviewMouseLeftButtonDown="OnPanMouseDown"
PreviewMouseMove="OnPanMouseMove"
PreviewMouseLeftButtonUp="OnPanMouseUp"
LostMouseCapture="OnPanLostCapture">
<Border Padding="24">
<Grid x:Name="World"
Background="Transparent"
HorizontalAlignment="Left" VerticalAlignment="Top"
Width="{Binding WorldWidth}" Height="{Binding WorldHeight}">
<Grid.LayoutTransform>
<ScaleTransform ScaleX="{Binding Zoom}" ScaleY="{Binding Zoom}"/>
</Grid.LayoutTransform>
<b:Interaction.Behaviors>
<bh:CanvasMouseBehavior/>
<bh:CanvasDropBehavior/>
</b:Interaction.Behaviors>
<!-- 층1: 페이지 스택 -->
<ItemsControl ItemsSource="{Binding Pages}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="0"/>
<Setter Property="Canvas.Top" Value="{Binding OffsetY}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<v:PageView/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- 층2: 선택/마퀴/가이드 오버레이 (표시 전용) -->
<v:SelectionOverlayView DataContext="{Binding Overlay}"/>
<!-- 층3: 인라인 텍스트 에디터 (더블클릭 시 코드비하인드가 배치) -->
<Canvas x:Name="EditorLayer"/>
</Grid>
</Border>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,294 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Views;
/// <summary>
/// 디자인 캔버스 뷰 — 코드비하인드 허용 범위: ①줌 휠 처리(커서 중심 스크롤 보정) ②캔버스 포커스 ③인라인 텍스트 에디터.
/// 문서 변경 로직은 두지 않는다(커밋은 DesignerViewModel.CommitInlineText 경유).
/// </summary>
public partial class DesignerCanvasView : UserControl
{
#region Member Fields
private DesignerViewModel? subscribedDesigner;
private TextBox? inlineEditor;
private ControlViewModel? editingTarget;
// 손(팬) 도구 — 플로팅 바 토글 또는 Space 누르는 동안
private bool isPanning;
private bool spacePanHeld;
private Point panStartPoint;
private double panStartHorizontal;
private double panStartVertical;
#endregion
#region Constructors
public DesignerCanvasView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
#endregion
#region Methods -
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (subscribedDesigner is not null)
{
subscribedDesigner.InlineEditRequested -= ShowInlineEditor;
}
CloseInlineEditor(commit: false);
subscribedDesigner = e.NewValue as DesignerViewModel;
if (subscribedDesigner is not null)
{
subscribedDesigner.InlineEditRequested += ShowInlineEditor;
}
}
/// <summary>대상 컨트롤 위에 편집기 표시 — 폰트/정렬 일치, 줌은 World LayoutTransform 이 처리</summary>
private void ShowInlineEditor(ControlViewModel target)
{
if (subscribedDesigner is null)
{
return;
}
CloseInlineEditor(commit: true);
// 데이터소스(MDataTable)는 인라인 대신 전용 쿼리 편집기 창
if (target is DataTableViewModel dataTable)
{
var queryDialog = new QueryEditorWindow(dataTable.Id, dataTable.Model.Props.GetText("Query") ?? string.Empty)
{
Owner = Window.GetWindow(this),
};
if (queryDialog.ShowDialog() == true)
{
subscribedDesigner.CommitPropertyText(dataTable, "Query", queryDialog.QueryText);
}
return;
}
var bounds = subscribedDesigner.WorldBoundsOf(target);
var multiline = target is LabelViewModel
|| (target is TextBoxViewModel textBox && textBox.Multiline);
var editor = new TextBox
{
// 종이 위 텍스트와 픽셀 일치가 목적 — 앱 테마의 암시 TextBox 템플릿(입력칩 라운드/MinHeight) 차단
Style = new Style(typeof(TextBox)),
Text = target.Text,
FontFamily = target.FontFamily,
FontSize = target.FontSize,
FontWeight = target.FontWeight,
FontStyle = target.FontStyle,
Foreground = target.Foreground,
Background = Brushes.White,
BorderBrush = new SolidColorBrush(Color.FromRgb(0x1E, 0x7B, 0xE8)),
BorderThickness = new Thickness(1),
Padding = new Thickness(1, 0, 1, 0),
MinWidth = Math.Max(40, bounds.Width),
MinHeight = bounds.Height,
MaxWidth = 640,
AcceptsReturn = multiline,
TextWrapping = multiline ? TextWrapping.Wrap : TextWrapping.NoWrap,
VerticalContentAlignment = multiline ? VerticalAlignment.Top : VerticalAlignment.Center,
ToolTip = multiline ? "Enter 확정 · Shift+Enter 줄바꿈 · Esc 취소" : "Enter 확정 · Esc 취소",
};
Canvas.SetLeft(editor, bounds.X);
Canvas.SetTop(editor, bounds.Y);
editor.PreviewKeyDown += OnEditorKeyDown;
editor.LostKeyboardFocus += OnEditorLostFocus;
editingTarget = target;
inlineEditor = editor;
EditorLayer.Children.Add(editor);
// 레이아웃 이후 포커스 + 전체 선택
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, () =>
{
editor.Focus();
editor.SelectAll();
});
}
private void OnEditorKeyDown(object sender, KeyEventArgs e)
{
if (inlineEditor is null)
{
return;
}
if (e.Key == Key.Escape)
{
e.Handled = true;
CloseInlineEditor(commit: false);
Focus();
}
else if (e.Key == Key.Enter && !Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
{
// Enter=확정, Shift+Enter=줄바꿈(여러 줄 편집기)
e.Handled = true;
CloseInlineEditor(commit: true);
Focus();
}
}
private void OnEditorLostFocus(object sender, KeyboardFocusChangedEventArgs e)
=> CloseInlineEditor(commit: true);
private void CloseInlineEditor(bool commit)
{
if (inlineEditor is null || editingTarget is null)
{
return;
}
var editor = inlineEditor;
var target = editingTarget;
inlineEditor = null;
editingTarget = null;
editor.PreviewKeyDown -= OnEditorKeyDown;
editor.LostKeyboardFocus -= OnEditorLostFocus;
EditorLayer.Children.Remove(editor);
if (commit)
{
subscribedDesigner?.CommitInlineText(target, editor.Text);
}
}
#endregion
#region Methods - ()
/// <summary>손 도구 활성 여부 — 플로팅 바 토글([200] 관행) 또는 Space 누르는 동안</summary>
private bool IsHandToolActive =>
spacePanHeld
|| (Application.Current.MainWindow?.DataContext as MainViewModel)?.IsHandTool == true;
/// <summary>팬 시작 — Preview 단계 선점으로 하위 선택/이동 로직(World 버블링) 차단</summary>
private void OnPanMouseDown(object sender, MouseButtonEventArgs e)
{
if (!IsHandToolActive)
{
return;
}
isPanning = true;
panStartPoint = e.GetPosition(Scroll);
panStartHorizontal = Scroll.HorizontalOffset;
panStartVertical = Scroll.VerticalOffset;
Scroll.CaptureMouse();
e.Handled = true;
}
private void OnPanMouseMove(object sender, MouseEventArgs e)
{
// 손 모드 커서 — 하위(World) 히트테스트 커서보다 우선하도록 ForceCursor
if (IsHandToolActive)
{
Scroll.Cursor = Cursors.Hand;
Scroll.ForceCursor = true;
}
else if (Scroll.ForceCursor)
{
Scroll.ForceCursor = false;
Scroll.Cursor = null;
}
if (isPanning)
{
var position = e.GetPosition(Scroll);
Scroll.ScrollToHorizontalOffset(panStartHorizontal - (position.X - panStartPoint.X));
Scroll.ScrollToVerticalOffset(panStartVertical - (position.Y - panStartPoint.Y));
e.Handled = true;
}
else if (IsHandToolActive)
{
e.Handled = true; // 손 모드 유휴 이동도 선점 — World 커서 갱신 억제
}
}
private void OnPanMouseUp(object sender, MouseButtonEventArgs e)
{
if (!isPanning)
{
return;
}
isPanning = false;
Scroll.ReleaseMouseCapture();
e.Handled = true;
}
private void OnPanLostCapture(object sender, MouseEventArgs e) => isPanning = false;
/// <summary>Space 누르는 동안 임시 손 도구 — 텍스트 입력 중에는 개입하지 않음</summary>
private void OnCanvasPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Space && !e.IsRepeat && e.OriginalSource is not TextBox)
{
spacePanHeld = true;
Scroll.Cursor = Cursors.Hand;
Scroll.ForceCursor = true;
e.Handled = true; // ScrollViewer 의 Space 페이지 스크롤 기본 동작 차단
}
}
private void OnCanvasPreviewKeyUp(object sender, KeyEventArgs e)
{
if (e.Key == Key.Space && spacePanHeld)
{
spacePanHeld = false;
if (!IsHandToolActive)
{
Scroll.ForceCursor = false;
Scroll.Cursor = null;
}
e.Handled = true;
}
}
#endregion
#region Methods -
/// <summary>Ctrl+휠 줌 — 커서 논리 위치를 유지하도록 스크롤 오프셋 보정</summary>
private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (Keyboard.Modifiers != ModifierKeys.Control)
{
return;
}
if (DataContext is not DesignerViewModel designer)
{
return;
}
e.Handled = true;
var oldZoom = designer.Zoom;
if (e.Delta > 0)
{
designer.ZoomIn();
}
else
{
designer.ZoomOut();
}
var newZoom = designer.Zoom;
if (Math.Abs(newZoom - oldZoom) < 0.0001)
{
return;
}
// 커서가 가리키는 논리 지점이 화면상 같은 곳에 남도록 오프셋 보정 (레이아웃 갱신 후)
var mouse = e.GetPosition(Scroll);
var factor = newZoom / oldZoom;
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, () =>
{
Scroll.ScrollToHorizontalOffset((Scroll.HorizontalOffset + mouse.X) * factor - mouse.X);
Scroll.ScrollToVerticalOffset((Scroll.VerticalOffset + mouse.Y) * factor - mouse.Y);
});
}
#endregion
}
@@ -0,0 +1,41 @@
<Window x:Class="SheetMe.Designer.Views.FontManagerDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="폰트 일괄 변경" Width="620" Height="560" MinWidth="520" MinHeight="420"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
<DockPanel Margin="12">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
Text="문서 전체의 (타입·글꼴·크기) 조합입니다. 변경할 조합을 선택(다중 가능)하고 새 글꼴을 지정하세요."/>
<!-- 새 글꼴 지정 + 적용 -->
<Border DockPanel.Dock="Bottom" Background="{DynamicResource B.Chip}" CornerRadius="4" Padding="10" Margin="0,10,0,0">
<StackPanel>
<DockPanel>
<TextBlock Text="새 글꼴" Width="60" VerticalAlignment="Center"/>
<ComboBox x:Name="FamilyBox" IsEditable="True" Width="180" HorizontalAlignment="Left"/>
<TextBlock Text="크기(pt)" Margin="14,0,6,0" VerticalAlignment="Center"/>
<TextBox x:Name="SizeBox" Width="50" Text="9" VerticalContentAlignment="Center"/>
<CheckBox x:Name="BoldBox" Content="굵게" Margin="14,0,0,0" VerticalAlignment="Center"/>
<CheckBox x:Name="ItalicBox" Content="기울임" Margin="10,0,0,0" VerticalAlignment="Center"/>
<CheckBox x:Name="UnderlineBox" Content="밑줄" Margin="10,0,0,0" VerticalAlignment="Center"/>
</DockPanel>
<DockPanel Margin="0,10,0,0">
<Button DockPanel.Dock="Right" Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
<Button DockPanel.Dock="Right" Content="전체에 적용" Padding="14,4" Margin="8,0,0,0" Click="OnApplyAll"/>
<Button DockPanel.Dock="Right" Content="선택 조합에 적용" Padding="14,4" Click="OnApplySelected"/>
<TextBlock x:Name="ResultText" VerticalAlignment="Center" Foreground="{DynamicResource B.Success}"/>
</DockPanel>
</StackPanel>
</Border>
<!-- 집계 목록 -->
<ListBox x:Name="GroupList" SelectionMode="Extended" FontSize="13"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Display}" Margin="2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Window>
@@ -0,0 +1,79 @@
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using SheetMe.Core.Serialization;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Views;
/// <summary>
/// 폰트 일괄 변경 대화상자 — 레거시 frmFontManager 이식.
/// 문서 전체 (타입·글꼴·크기·스타일) 집계 → 선택 조합 또는 전체에 새 글꼴 일괄 적용(Undo 1스텝).
/// </summary>
public partial class FontManagerDialogView : Window
{
#region Member Fields
private readonly DesignerViewModel designer;
#endregion
#region Constructors
public FontManagerDialogView(DesignerViewModel designer)
{
this.designer = designer;
InitializeComponent();
FamilyBox.ItemsSource = Fonts.SystemFontFamilies
.Select(f => f.FamilyNames.Values.FirstOrDefault() ?? f.Source)
.Distinct()
.OrderBy(name => name, StringComparer.CurrentCulture)
.ToList();
FamilyBox.Text = "굴림";
Reload();
}
#endregion
#region Methods
private void Reload()
{
GroupList.ItemsSource = designer.CollectFontUsage();
}
private void OnApplySelected(object sender, RoutedEventArgs e)
=> Apply(GroupList.SelectedItems.Cast<FontUsageGroup>().ToList());
private void OnApplyAll(object sender, RoutedEventArgs e)
=> Apply(GroupList.Items.Cast<FontUsageGroup>().ToList());
private void Apply(List<FontUsageGroup> targets)
{
if (targets.Count == 0)
{
MessageBox.Show("변경할 조합을 선택하세요.", "폰트 일괄 변경");
return;
}
var family = FamilyBox.Text.Trim();
if (family.Length == 0)
{
MessageBox.Show("글꼴명을 입력하세요.", "폰트 일괄 변경");
return;
}
if (!double.TryParse(SizeBox.Text.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var sizePt)
|| sizePt <= 0 || sizePt > 200)
{
MessageBox.Show("크기(pt)를 올바르게 입력하세요.", "폰트 일괄 변경");
return;
}
var newFont = new LegacyFont
{
Family = family,
SizePt = sizePt,
Bold = BoldBox.IsChecked == true,
Italic = ItalicBox.IsChecked == true,
Underline = UnderlineBox.IsChecked == true,
};
var changed = designer.ApplyFontBulk(targets, newFont);
ResultText.Text = $"{changed}개 컨트롤 변경됨";
Reload();
}
#endregion
}
@@ -0,0 +1,141 @@
<UserControl x:Class="SheetMe.Designer.Views.InspectorView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ins="clr-namespace:SheetMe.Designer.ViewModels.Inspector"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance ins:InspectorViewModel}">
<UserControl.Resources>
<!-- 행 VM 타입별 암시적 템플릿 -->
<Style x:Key="RowLabel" TargetType="TextBlock">
<Setter Property="Width" Value="86"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<Style x:Key="RowEditor" TargetType="Control">
<Setter Property="FontSize" Value="12"/>
</Style>
<DataTemplate DataType="{x:Type ins:SectionRowViewModel}">
<TextBlock Text="{Binding Label}" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"
Margin="0,10,0,4" FontSize="12"/>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:TextRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"/>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:MultilineTextRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,0,0"/>
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="48" MaxHeight="120"
VerticalScrollBarVisibility="Auto"/>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:NumberRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"/>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:ToggleRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<CheckBox IsChecked="{Binding IsOn}" VerticalAlignment="Center"/>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:ChoiceRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<ComboBox ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}" FontSize="12"/>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:TagPickerRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<Button DockPanel.Dock="Right" Content="…" Width="24" Margin="4,0,0,0"
Command="{Binding BrowseCommand}" ToolTip="목록에서 선택"/>
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"
ToolTip="직접 입력 또는 … 버튼으로 선택"/>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<TextBlock Text="{Binding ValueText}" FontSize="11" Foreground="{DynamicResource B.Muted}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:ToggleAdvancedRowViewModel}">
<Button Content="{Binding ButtonText}" Command="{Binding ToggleCommand}"
Margin="0,6,0,2" Padding="6,3" HorizontalAlignment="Stretch"
Background="{DynamicResource B.Chip}" BorderBrush="{DynamicResource B.Line2}" FontSize="11" Foreground="{DynamicResource B.Muted}"/>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:AddPropertyRowViewModel}">
<DockPanel Margin="0,6,0,2">
<Button DockPanel.Dock="Right" Content="추가" Padding="8,2" Margin="4,0,0,0"
Command="{Binding AddCommand}"
ToolTip="레거시 컨트롤의 속성 이름을 정확히 입력하세요 (예: EnterTabYon, AutoHeight)"/>
<TextBox Text="{Binding KeyText, UpdateSourceTrigger=PropertyChanged}" FontSize="12"
ToolTip="새 속성 키 입력"/>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:QueryRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<Button DockPanel.Dock="Right" Content="편집…" Padding="6,1" Margin="4,0,0,0"
Command="{Binding EditCommand}" ToolTip="쿼리 편집기 열기 (데이터소스 더블클릭과 동일)"/>
<TextBlock Text="{Binding Summary}" FontSize="11" Foreground="{DynamicResource B.Muted}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
ToolTip="{Binding ValueText}"/>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:ColorRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<!-- 스와치 = 피커 버튼(클릭 → 색상 선택 대화상자), 직접 입력도 병행 -->
<Button DockPanel.Dock="Right" Width="26" Height="22" Margin="4,0,0,0" Padding="0"
Command="{Binding PickCommand}" Cursor="Hand"
ToolTip="클릭하여 색상 선택">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{Binding Preview}" BorderBrush="{DynamicResource B.Line2}"
BorderThickness="1" CornerRadius="4" SnapsToDevicePixels="True"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<TextBox Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}" FontSize="12"
ToolTip="R, G, B 또는 색 이름 (예: 224, 224, 224 / White) — 오른쪽 스와치 클릭 시 피커"/>
</DockPanel>
</DataTemplate>
</UserControl.Resources>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding Summary}" FontWeight="Bold"
Margin="10,10,10,2" Foreground="{DynamicResource B.Muted}" TextTrimming="CharacterEllipsis"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Rows}" Margin="10,0,10,10"/>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -0,0 +1,12 @@
using System.Windows.Controls;
namespace SheetMe.Designer.Views;
/// <summary>속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.</summary>
public partial class InspectorView : UserControl
{
public InspectorView()
{
InitializeComponent();
}
}
+540
View File
@@ -0,0 +1,540 @@
<Window x:Class="SheetMe.Designer.Views.MainView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
xmlns:v="clr-namespace:SheetMe.Designer.Views"
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
xmlns:bh="clr-namespace:SheetMe.Designer.Behaviors"
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance vm:MainViewModel}"
Title="{Binding Title}"
Icon="pack://application:,,,/SheetMe.Designer;component/Assets/sheetme.ico"
Width="1440" Height="920" MinWidth="940" MinHeight="640"
WindowStyle="None" WindowStartupLocation="CenterScreen"
Background="{DynamicResource B.AppBg}" FontFamily="Malgun Gothic" FontSize="13"
Loaded="OnWindowLoaded" StateChanged="OnWindowStateChanged">
<!-- GlassFrameThickness 0,0,0,1: DWM 창 그림자 활성화([200]SheetMe 크롬과 동일) -->
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome CaptionHeight="40" ResizeBorderThickness="6" GlassFrameThickness="0,0,0,1"
CornerRadius="0" UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Window.DataContext>
<vm:MainViewModel/>
</Window.DataContext>
<Window.Resources>
<ctl:TypeToIconConverter x:Key="TypeToIcon"/>
<ctl:TypeToCategoryConverter x:Key="TypeToCategory"/>
<ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:InverseBoolConverter x:Key="InverseBool"/>
<!-- 팔레트(도구 상자/플라이아웃 공용) — 카테고리 그룹 뷰 -->
<CollectionViewSource x:Key="PaletteGrouped" Source="{Binding PaletteItems}">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="Type" Converter="{StaticResource TypeToCategory}"/>
</CollectionViewSource.GroupDescriptions>
</CollectionViewSource>
<!-- 도구 상자 타일 카드([200] 컴포넌트 카드 룩) — 선택 하이라이트 없음 -->
<Style x:Key="PaletteTile" TargetType="ListBoxItem">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}"
BorderThickness="1" CornerRadius="5" Margin="3" SnapsToDevicePixels="True">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 팔레트 카테고리 그룹 헤더 -->
<Style x:Key="PaletteGroupHeader" TargetType="TextBlock">
<Setter Property="FontSize" Value="10.5"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="Margin" Value="5,9,0,4"/>
</Style>
<!-- 플라이아웃/줌 팝업 행 버튼 — 왼정렬 hover 행 -->
<Style x:Key="FlyoutRow" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource B.Ink}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="12,7"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{TemplateBinding Background}" CornerRadius="6"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Hover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<DockPanel>
<!-- ===== 커스텀 타이틀바: 로고 + 메뉴 | 중앙 제목 | 창 제어 ===== -->
<Border DockPanel.Dock="Top" Background="{DynamicResource B.Titlebar}" Height="40"
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 좌: 로고 + 메뉴 -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Image Source="pack://application:,,,/SheetMe.Designer;component/Assets/sheetme-logo.png"
Width="20" Height="20" VerticalAlignment="Center" RenderOptions.BitmapScalingMode="HighQuality"/>
<Menu VerticalAlignment="Center" Margin="8,0,0,0">
<MenuItem Header="파일(_F)">
<MenuItem Header="새 서식(_N)" Command="{Binding NewFileCommand}" InputGestureText="Ctrl+N"/>
<MenuItem Header="열기(_O)..." Command="{Binding OpenFileCommand}" InputGestureText="Ctrl+O"/>
<Separator/>
<MenuItem Header="저장(_S)" Command="{Binding SaveFileCommand}" InputGestureText="Ctrl+S"/>
<MenuItem Header="다른 이름으로 저장(_A)..." Command="{Binding SaveAsFileCommand}"/>
<Separator/>
<MenuItem Header="DB에서 열기(_D)..." Command="{Binding OpenFromDbCommand}"/>
<MenuItem Header="DB에 저장(_B)..." Command="{Binding SaveToDbCommand}"/>
<Separator/>
<MenuItem Header="JSON 가져오기(_I)..." Command="{Binding ImportJsonCommand}"/>
<MenuItem Header="JSON 내보내기(_E)..." Command="{Binding ExportJsonCommand}"/>
<Separator/>
<MenuItem Header="미리보기(_V)..." Command="{Binding PreviewCommand}"/>
<MenuItem Header="인쇄(_P)..." Command="{Binding PrintCommand}" InputGestureText="Ctrl+P"/>
<Separator/>
<MenuItem Header="종료(_X)" Command="{Binding ExitCommand}"/>
</MenuItem>
<MenuItem Header="편집(_E)">
<MenuItem Header="실행 취소(_U)" Command="{Binding CurrentDesigner.UndoCommand}" InputGestureText="Ctrl+Z"/>
<MenuItem Header="다시 실행(_R)" Command="{Binding CurrentDesigner.RedoCommand}" InputGestureText="Ctrl+Y"/>
<Separator/>
<MenuItem Header="잘라내기(_T)" Command="{Binding CurrentDesigner.CutCommand}" InputGestureText="Ctrl+X"/>
<MenuItem Header="복사(_C)" Command="{Binding CurrentDesigner.CopyCommand}" InputGestureText="Ctrl+C"/>
<MenuItem Header="붙여넣기(_P)" Command="{Binding CurrentDesigner.PasteCommand}" InputGestureText="Ctrl+V"/>
<MenuItem Header="복제(_D)" Command="{Binding CurrentDesigner.DuplicateCommand}" InputGestureText="Ctrl+D"/>
<MenuItem Header="삭제(_L)" Command="{Binding CurrentDesigner.DeleteCommand}" InputGestureText="Del"/>
<Separator/>
<MenuItem Header="전체 선택(_A)" Command="{Binding CurrentDesigner.SelectAllCommand}" InputGestureText="Ctrl+A"/>
</MenuItem>
<MenuItem Header="배치(_A)">
<MenuItem Header="맨 앞으로(_F)" Command="{Binding CurrentDesigner.BringToFrontCommand}"/>
<MenuItem Header="맨 뒤로(_B)" Command="{Binding CurrentDesigner.SendToBackCommand}"/>
<Separator/>
<MenuItem Header="그룹(_G)" Command="{Binding CurrentDesigner.GroupCommand}" InputGestureText="Ctrl+G"/>
<MenuItem Header="그룹 해제(_U)" Command="{Binding CurrentDesigner.UngroupCommand}" InputGestureText="Ctrl+Shift+G"/>
<Separator/>
<MenuItem Header="왼쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Left"/>
<MenuItem Header="오른쪽 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Right"/>
<MenuItem Header="위 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Top"/>
<MenuItem Header="아래 맞춤" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="Bottom"/>
<MenuItem Header="가로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterH"/>
<MenuItem Header="세로 가운데" Command="{Binding CurrentDesigner.AlignCommand}" CommandParameter="CenterV"/>
<Separator/>
<MenuItem Header="같은 크기로">
<MenuItem Header="너비/높이 모두(_B)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Both"/>
<MenuItem Header="너비(_W)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Width"/>
<MenuItem Header="높이(_H)" Command="{Binding CurrentDesigner.SizeToControlCommand}" CommandParameter="Height"/>
</MenuItem>
<MenuItem Header="가로 간격">
<MenuItem Header="균등(_E)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizEqual"/>
<MenuItem Header="넓게(_I)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizIncrease"/>
<MenuItem Header="좁게(_D)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizDecrease"/>
<MenuItem Header="붙이기(_C)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="HorizConcat"/>
</MenuItem>
<MenuItem Header="세로 간격">
<MenuItem Header="균등(_E)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertEqual"/>
<MenuItem Header="넓게(_I)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertIncrease"/>
<MenuItem Header="좁게(_D)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertDecrease"/>
<MenuItem Header="붙이기(_C)" Command="{Binding CurrentDesigner.SpacingCommand}" CommandParameter="VertConcat"/>
</MenuItem>
<Separator/>
<MenuItem Header="페이지 가로 가운데(_H)" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="H"/>
<MenuItem Header="페이지 세로 가운데(_V)" Command="{Binding CurrentDesigner.CenterInPageCommand}" CommandParameter="V"/>
<Separator/>
<MenuItem Header="{Binding CurrentDesigner.TabOrderMenuHeader, FallbackValue=탭순서 편집 시작}"
Command="{Binding CurrentDesigner.TabOrderCommand}"
IsChecked="{Binding CurrentDesigner.IsTabOrderMode, Mode=OneWay}"
ToolTip="입력 컨트롤을 원하는 입력 순서대로 클릭한 뒤 완료 — Esc 취소"/>
</MenuItem>
<MenuItem Header="보기(_V)">
<MenuItem Header="확대(_I)" Command="{Binding ZoomInCommand}" InputGestureText="Ctrl+휠↑"/>
<MenuItem Header="축소(_O)" Command="{Binding ZoomOutCommand}" InputGestureText="Ctrl+휠↓"/>
<MenuItem Header="100%(_R)" Command="{Binding ZoomResetCommand}"/>
<Separator/>
<MenuItem x:Name="ThemeMenuItem" Header="다크 테마(_D)" IsCheckable="True" Click="OnThemeToggleClick"/>
</MenuItem>
<MenuItem Header="도구(_T)">
<MenuItem Header="서식 수정이력(_H)..." Command="{Binding SheetHistoryCommand}"/>
<Separator/>
<MenuItem Header="상용구 관리(_W)..." Command="{Binding RecordWordCommand}"/>
<MenuItem Header="폰트 일괄 변경(_F)..." Command="{Binding FontManagerCommand}"/>
</MenuItem>
</Menu>
</StackPanel>
<!-- 중앙: 창 제목 -->
<TextBlock Grid.Column="1" Text="{Binding Title}" Foreground="{DynamicResource B.Muted}"
HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="12"/>
<!-- 우: 창 제어 -->
<StackPanel Grid.Column="2" Orientation="Horizontal" shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Button Style="{StaticResource CaptionBtn}" Click="OnMinimizeClick" ToolTip="최소화">
<TextBlock Text="&#xE921;" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
</Button>
<Button Style="{StaticResource CaptionBtn}" Click="OnMaxRestoreClick" ToolTip="최대화/복원">
<TextBlock x:Name="MaxGlyph" Text="&#xE922;" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
</Button>
<Button Style="{StaticResource CloseBtn}" Click="OnCloseWinClick" ToolTip="닫기">
<TextBlock Text="&#xE8BB;" FontFamily="Segoe MDL2 Assets" FontSize="10"/>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- ===== 하단 상태바 (줌은 플로팅 바로 이동) ===== -->
<Border DockPanel.Dock="Bottom" Background="{DynamicResource B.Titlebar}"
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,1,0,0" Padding="12,6">
<TextBlock Text="{Binding StatusText}" Foreground="{DynamicResource B.Muted}" FontSize="12"
TextTrimming="CharacterEllipsis"/>
</Border>
<!-- ===== 본문 3열: 좌 탭 패널 | 문서 탭+캔버스+플로팅 바 | 속성 ([200] 레이아웃) ===== -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="232" MinWidth="180" MaxWidth="480"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="280" MinWidth="220" MaxWidth="560"/>
</Grid.ColumnDefinitions>
<!-- 좌: 알약 탭 — 서식 목록 / 레이어 / 도구 상자 -->
<Border Grid.Column="0" Background="{DynamicResource B.Panel}">
<TabControl x:Name="LeftTabs" SelectedIndex="{Binding SelectedLeftTabIndex, Mode=TwoWay}">
<!-- 서식 목록 -->
<TabItem>
<TabItem.Header><TextBlock Text="서식 목록" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="8,8,8,4">
<Button DockPanel.Dock="Right" Content="검색" Margin="6,0,0,0"
Command="{Binding SearchSheetsCommand}"/>
<TextBox Text="{Binding SheetSearchKeyword, UpdateSourceTrigger=PropertyChanged}"
ToolTip="서식명/코드 검색 (Enter)">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding SearchSheetsCommand}"/>
</TextBox.InputBindings>
</TextBox>
</DockPanel>
<Grid>
<ListBox x:Name="SheetListBox" ItemsSource="{Binding SheetList}"
Margin="4,0,4,4"
VirtualizingPanel.IsVirtualizing="True"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
MouseDoubleClick="OnSheetListDoubleClick"
ToolTip="더블클릭으로 열기 — 여러 서식을 탭으로 동시에 편집할 수 있습니다">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="1">
<TextBlock Text="{Binding ShtCod}" FontSize="11" Foreground="{DynamicResource B.Muted}"
Width="52" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Style.Triggers>
<DataTrigger Binding="{Binding HasDesign}" Value="False">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="Opacity" Value="0.6"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Text="불러오는 중..." HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource B.Muted}"
Visibility="{Binding IsSheetListLoading, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
</DockPanel>
</TabItem>
<!-- 레이어 (+ 페이지) -->
<TabItem>
<TabItem.Header><TextBlock Text="레이어" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
<DockPanel>
<!-- 페이지 섹션 -->
<DockPanel DockPanel.Dock="Top" Margin="0,4,0,0">
<TextBlock DockPanel.Dock="Top" Text="페이지" FontWeight="Bold" FontSize="11"
Margin="10,4,10,4" Foreground="{DynamicResource B.Muted}"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="10,2,10,6">
<Button Content="추가" Command="{Binding CurrentDesigner.AddPageCommand}"/>
<Button Content="삭제" Margin="6,0,0,0" Command="{Binding CurrentDesigner.RemovePageCommand}"/>
</StackPanel>
<ListBox ItemsSource="{Binding CurrentDesigner.Pages}"
SelectedItem="{Binding CurrentDesigner.SelectedPage}"
MaxHeight="110" Margin="4,0">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="2,1">
<TextBlock Text="{Binding Index, StringFormat=페이지 {0}}" FontSize="12"/>
<TextBlock Text="{Binding SizeText, StringFormat= ({0})}" FontSize="11" Foreground="{DynamicResource B.Muted}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<Border DockPanel.Dock="Top" Height="1" Background="{DynamicResource B.Line}" Margin="8,2"/>
<!-- 레이어 목록 (활성 페이지, 그리기 순서) -->
<ListBox ItemsSource="{Binding CurrentDesigner.SelectedPage.Controls}"
SelectedItem="{Binding CurrentDesigner.SelectedLayerItem}"
Margin="4,2,4,6"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,1">
<CheckBox DockPanel.Dock="Right" IsChecked="{Binding IsLockedFlag}"
ToolTip="잠금" Margin="4,0,0,0"/>
<CheckBox DockPanel.Dock="Right" IsChecked="{Binding IsHiddenFlag}"
ToolTip="숨김" Margin="4,0,0,0"/>
<TextBlock Text="{Binding Id}" FontSize="12" TextTrimming="CharacterEllipsis"/>
</DockPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</TabItem>
<!-- 도구 상자 ([200] 타일 카드 룩 — 드래그/더블클릭 배치) -->
<TabItem>
<TabItem.Header><TextBlock Text="도구 상자" Style="{StaticResource TabHeaderText}"/></TabItem.Header>
<ListBox ItemsSource="{Binding Source={StaticResource PaletteGrouped}}"
ItemContainerStyle="{StaticResource PaletteTile}"
Margin="6,4,6,6"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ToolTip="더블클릭 또는 드래그로 캔버스에 배치">
<b:Interaction.Behaviors>
<bh:PaletteDragBehavior/>
</b:Interaction.Behaviors>
<!-- 그룹 세로 나열(GroupStyle.Panel 기본) + 그룹 내 타일 2열(ItemsPanel — 그룹 내부 항목이 상속) -->
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="2"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" Style="{StaticResource PaletteGroupHeader}"/>
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ListBox.GroupStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel HorizontalAlignment="Center" Margin="2,9,2,8" Background="Transparent">
<ContentControl Content="{Binding Type, Converter={StaticResource TypeToIcon}}"
HorizontalAlignment="Center" Margin="0,0,0,5" IsTabStop="False" Focusable="False"/>
<TextBlock Text="{Binding DisplayName}" FontSize="11" TextAlignment="Center"
HorizontalAlignment="Center" TextWrapping="Wrap"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</TabItem>
</TabControl>
</Border>
<GridSplitter Grid.Column="1" Style="{StaticResource ColSplitter}" ToolTip="좌측 패널 너비 조절"/>
<!-- 중앙: 문서 탭(크롬식) + 캔버스 + 플로팅 팔레트 바 -->
<Grid Grid.Column="2" Background="{DynamicResource B.CanvasBg}">
<TabControl Style="{StaticResource DocTabs}"
ItemsSource="{Binding OpenDesigners}"
SelectedItem="{Binding CurrentDesigner}"
ItemContainerStyle="{StaticResource DocTabItem}">
<TabControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<ContentControl Content="{Binding Source=file-text, Converter={StaticResource IconName}, ConverterParameter=12}"
Margin="0,0,6,0" VerticalAlignment="Center" IsTabStop="False" Focusable="False"/>
<TextBlock Text="{Binding DisplayName}" MaxWidth="170" Style="{StaticResource TabHeaderText}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<Button Content="✕" FontSize="9" Margin="7,0,0,0"
Style="{StaticResource Subtle}" Padding="3,0" Cursor="Hand" ToolTip="문서 닫기"
Command="{Binding DataContext.CloseDocumentCommand,
RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
</StackPanel>
</DataTemplate>
</TabControl.ItemTemplate>
<TabControl.ContentTemplate>
<DataTemplate>
<v:DesignerCanvasView/>
</DataTemplate>
</TabControl.ContentTemplate>
</TabControl>
<!-- 플로팅 팔레트 바 (Figma 식 — 컨트롤 추가 플라이아웃 | 줌) -->
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
CornerRadius="12" Padding="7,5" HorizontalAlignment="Center" VerticalAlignment="Bottom"
Margin="0,0,0,20">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="3" Opacity="0.35" Color="Black"/>
</Border.Effect>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ToggleButton Width="38" Height="34" Padding="0" Focusable="False"
ToolTip="선택 도구 — 클릭으로 선택, 드래그로 이동"
IsChecked="{Binding IsHandTool, Converter={StaticResource InverseBool}, Mode=OneWay}"
Click="OnSelectToolClick"
Content="{Binding Source=mouse-pointer-click, Converter={StaticResource IconName}, ConverterParameter=18}"/>
<ToggleButton Width="38" Height="34" Padding="0" Focusable="False"
ToolTip="손 도구 — 드래그로 화면 이동 (Space 누르는 동안 임시)"
IsChecked="{Binding IsHandTool, Mode=OneWay}"
Click="OnHandToolClick"
Content="{Binding Source=hand, Converter={StaticResource IconName}, ConverterParameter=18}"/>
<Border Width="1" Height="22" Background="{DynamicResource B.Line}" Margin="5,0" VerticalAlignment="Center"/>
<Button x:Name="AddControlBtn" Style="{StaticResource Subtle}" Width="38" Height="34" Padding="0"
ToolTip="컨트롤 추가 — 활성 페이지 중앙에 배치" Click="OnAddControlFlyoutClick"
Content="{Binding Source=layout-grid, Converter={StaticResource IconName}, ConverterParameter=18}"/>
<Border Width="1" Height="22" Background="{DynamicResource B.Line}" Margin="5,0" VerticalAlignment="Center"/>
<Button Style="{StaticResource Subtle}" Width="30" Height="34" Padding="0" ToolTip="축소 (Ctrl+휠↓)"
Command="{Binding ZoomOutCommand}"
Content="{Binding Source=zoom-out, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<Button x:Name="ZoomBtn" Style="{StaticResource Subtle}" Height="34" Padding="8,0,6,0" ToolTip="줌"
Click="OnZoomFlyoutClick">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding CurrentDesigner.ZoomPercentText, FallbackValue=100%}"
FontSize="12" MinWidth="38" TextAlignment="Center" VerticalAlignment="Center"/>
<ContentControl Content="{Binding Source=chevron-down, Converter={StaticResource IconName}, ConverterParameter=11}"
Margin="3,1,0,0" VerticalAlignment="Center" IsTabStop="False" Focusable="False"/>
</StackPanel>
</Button>
<Button Style="{StaticResource Subtle}" Width="30" Height="34" Padding="0" ToolTip="확대 (Ctrl+휠↑)"
Command="{Binding ZoomInCommand}"
Content="{Binding Source=zoom-in, Converter={StaticResource IconName}, ConverterParameter=16}"/>
</StackPanel>
</Border>
<!-- 컨트롤 추가 플라이아웃 (카테고리별 타일) -->
<Popup x:Name="AddControlPopup" PlacementTarget="{Binding ElementName=AddControlBtn}" Placement="Top"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade" VerticalOffset="-8">
<Border Margin="18" Background="{DynamicResource B.Panel}" BorderBrush="{DynamicResource B.Line}"
BorderThickness="1" CornerRadius="10" MinWidth="300">
<Border.Effect>
<DropShadowEffect BlurRadius="16" ShadowDepth="3" Opacity="0.4" Color="Black"/>
</Border.Effect>
<ScrollViewer MaxHeight="440" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Source={StaticResource PaletteGrouped}}" Margin="10,9,10,10">
<!-- 그룹 세로 나열 + 그룹 내 타일 4열([200] 플라이아웃 치수) -->
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="4"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.GroupStyle>
<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" FontSize="10.5" FontWeight="Bold"
Foreground="{DynamicResource B.Muted}" Margin="2,9,0,6"/>
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ItemsControl.GroupStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Width="66" Margin="3" Padding="4,9,4,8" Click="OnFlyoutTileClick"
Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}"
ToolTip="{Binding DisplayName}">
<StackPanel HorizontalAlignment="Center">
<ContentControl Content="{Binding Type, Converter={StaticResource TypeToIcon}}"
HorizontalAlignment="Center" Margin="0,0,0,5" IsTabStop="False" Focusable="False"/>
<TextBlock Text="{Binding DisplayName}" FontSize="11" TextAlignment="Center"
HorizontalAlignment="Center"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Popup>
<!-- 줌 플라이아웃 -->
<Popup x:Name="ZoomPopup" PlacementTarget="{Binding ElementName=ZoomBtn}" Placement="Top"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade" VerticalOffset="-8">
<Border Margin="18" Background="{DynamicResource B.Panel}" BorderBrush="{DynamicResource B.Line}"
BorderThickness="1" CornerRadius="10" MinWidth="190">
<Border.Effect>
<DropShadowEffect BlurRadius="16" ShadowDepth="3" Opacity="0.4" Color="Black"/>
</Border.Effect>
<StackPanel Margin="5">
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomInCommand}" Click="OnZoomRowClick">
<DockPanel LastChildFill="True">
<TextBlock DockPanel.Dock="Right" Text="Ctrl+휠↑" Foreground="{DynamicResource B.Muted}"
FontSize="11.5" Margin="24,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="확대" FontSize="12.5" VerticalAlignment="Center"/>
</DockPanel>
</Button>
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomOutCommand}" Click="OnZoomRowClick">
<DockPanel LastChildFill="True">
<TextBlock DockPanel.Dock="Right" Text="Ctrl+휠↓" Foreground="{DynamicResource B.Muted}"
FontSize="11.5" Margin="24,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="축소" FontSize="12.5" VerticalAlignment="Center"/>
</DockPanel>
</Button>
<Border Height="1" Background="{DynamicResource B.Line}" Margin="8,5"/>
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomResetCommand}" Click="OnZoomRowClick">
<TextBlock Text="100%" FontSize="12.5" VerticalAlignment="Center"/>
</Button>
</StackPanel>
</Border>
</Popup>
</Grid>
<GridSplitter Grid.Column="3" Style="{StaticResource ColSplitter}" ToolTip="속성 패널 너비 조절"/>
<!-- 우: 속성 인스펙터 -->
<Border Grid.Column="4" Background="{DynamicResource B.Panel}">
<DockPanel>
<Border DockPanel.Dock="Top" Background="{DynamicResource B.PanelHeader}" Padding="11,8"
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
<TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/>
</Border>
<v:InspectorView DataContext="{Binding CurrentDesigner.Inspector}"/>
</DockPanel>
</Border>
</Grid>
</DockPanel>
</Window>
+110
View File
@@ -0,0 +1,110 @@
using System.Windows;
using SheetMe.Data.Stores;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Views;
/// <summary>
/// 메인 셸 윈도우 — 커스텀 타이틀바(로고+메뉴) | 좌(서식 목록/도구상자/페이지/레이어) | 문서 탭 | 인스펙터.
/// [200]SheetMe 크롬(WindowChrome CaptionHeight 40) 이식 — 창 제어/테마 토글은 뷰 전용 관심사라 코드비하인드 처리.
/// </summary>
public partial class MainView : Window
{
public MainView()
{
InitializeComponent();
// StaysOpen=False 팝업은 바깥 클릭(버튼 포함)으로 먼저 닫힌다 — 토글 버튼 재클릭이 곧바로 다시 열지 않게 닫힌 시각 기록
AddControlPopup.Closed += (_, _) => addControlPopupClosedAt = Environment.TickCount;
ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount;
}
/// <summary>기동 — 테마 메뉴 체크 동기화 + 뷰모델 Loaded 커맨드 실행(구 CustomWindow.LoadedCommand 대체)</summary>
private void OnWindowLoaded(object sender, RoutedEventArgs e)
{
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
if (DataContext is MainViewModel viewModel && viewModel.LoadedCommand?.CanExecute(null) == true)
{
viewModel.LoadedCommand.Execute(null);
}
}
/// <summary>서식 목록 더블클릭 → 탭으로 열기(뷰모델 위임 — ListBox 더블클릭은 InputBinding 미지원)</summary>
private void OnSheetListDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataContext is MainViewModel viewModel)
{
viewModel.OpenSheetFromList(SheetListBox.SelectedItem as SheetSummary);
}
}
private void OnThemeToggleClick(object sender, RoutedEventArgs e)
{
ThemeManager.Toggle();
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
}
/// <summary>플로팅 바 — 컨트롤 추가 플라이아웃 토글(닫힘 직후 재클릭 가드)</summary>
private void OnAddControlFlyoutClick(object sender, RoutedEventArgs e)
{
if (Environment.TickCount - addControlPopupClosedAt > 150)
{
AddControlPopup.IsOpen = true;
}
}
/// <summary>플로팅 바 — 줌 플라이아웃 토글</summary>
private void OnZoomFlyoutClick(object sender, RoutedEventArgs e)
{
if (Environment.TickCount - zoomPopupClosedAt > 150)
{
ZoomPopup.IsOpen = true;
}
}
/// <summary>플라이아웃 타일 클릭 — 활성 페이지 중앙에 컨트롤 배치(팔레트 더블클릭과 동일 경로)</summary>
private void OnFlyoutTileClick(object sender, RoutedEventArgs e)
{
AddControlPopup.IsOpen = false;
if ((sender as FrameworkElement)?.DataContext is SheetMe.Core.Catalog.ControlDescriptor item
&& DataContext is MainViewModel viewModel)
{
viewModel.CurrentDesigner?.AddPaletteItemAtCenter(item.Type);
}
}
/// <summary>줌 플라이아웃 행 클릭 — 커맨드 실행 후 팝업 닫기</summary>
private void OnZoomRowClick(object sender, RoutedEventArgs e) => ZoomPopup.IsOpen = false;
/// <summary>플로팅 바 — 선택 도구(기본)</summary>
private void OnSelectToolClick(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel viewModel)
{
viewModel.IsHandTool = false;
}
}
/// <summary>플로팅 바 — 손(팬) 도구</summary>
private void OnHandToolClick(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel viewModel)
{
viewModel.IsHandTool = true;
}
}
private int addControlPopupClosedAt;
private int zoomPopupClosedAt;
private void OnMinimizeClick(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void OnMaxRestoreClick(object sender, RoutedEventArgs e) =>
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
private void OnCloseWinClick(object sender, RoutedEventArgs e) => Close();
/// <summary>최대화/복원 글리프 전환(Segoe MDL2: E922=최대화, E923=복원)</summary>
private void OnWindowStateChanged(object sender, EventArgs e) =>
MaxGlyph.Text = WindowState == WindowState.Maximized ? "" : "";
}
+46
View File
@@ -0,0 +1,46 @@
<UserControl x:Class="SheetMe.Designer.Views.PageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance vm:PageViewModel}">
<!-- 종이 위 렌더는 레거시 충실 유지 — 앱 다크 테마의 암시 TextBlock 스타일(B.Ink 밝은 글자)이
페이지 콘텐츠에 스미지 않게 여기서 기본(검정) 암시 스타일로 차단 -->
<UserControl.Resources>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="Black"/>
</Style>
</UserControl.Resources>
<!-- 용지 1장: 그림자 + 종이 + 컨트롤 층. 텍스트 선명도는 Ideal 포맷팅 -->
<Grid Width="{Binding WidthDip}" Height="{Binding HeightDip}"
TextOptions.TextFormattingMode="Ideal">
<!-- 종이 그림자 (Effect 대신 오프셋 사각형 — 성능) -->
<Border Margin="3,3,-3,-3" Background="#22000000"/>
<!-- 종이 -->
<Border Background="{Binding PaperBrush}" BorderBrush="#D8DCE2" BorderThickness="1"/>
<!-- 컨트롤 층 (그리기 순서 = 컬렉션 순서, 마지막이 최상위) -->
<ItemsControl ItemsSource="{Binding Controls}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
<Setter Property="Width" Value="{Binding Width}"/>
<Setter Property="Height" Value="{Binding Height}"/>
<Setter Property="Opacity" Value="{Binding DesignOpacity}"/>
</Style>
</ItemsControl.ItemContainerStyle>
</ItemsControl>
</Grid>
</UserControl>
@@ -0,0 +1,12 @@
using System.Windows.Controls;
namespace SheetMe.Designer.Views;
/// <summary>용지 1장 렌더 뷰 — DataContext 는 PageViewModel.</summary>
public partial class PageView : UserControl
{
public PageView()
{
InitializeComponent();
}
}
@@ -0,0 +1,25 @@
<Window x:Class="SheetMe.Designer.Views.PreviewWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="미리보기" Width="900" Height="1000"
WindowStartupLocation="CenterOwner" Background="{DynamicResource B.CanvasBg}">
<DockPanel>
<ToolBarTray DockPanel.Dock="Top">
<ToolBar>
<Button Content="인쇄..." Padding="10,3" Click="OnPrint"/>
<Separator/>
<Button Content="" Padding="8,3" Click="OnZoomOut"/>
<TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/>
<Button Content="" Padding="8,3" Click="OnZoomIn"/>
</ToolBar>
</ToolBarTray>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center">
<StackPanel.LayoutTransform>
<ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/>
</StackPanel.LayoutTransform>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,73 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Views;
/// <summary>미리보기 창 — 편집 크롬 없는 페이지 렌더(인쇄와 동일 비주얼) + 줌/인쇄.</summary>
public partial class PreviewWindow : Window
{
#region Member Fields
private readonly DesignerViewModel designer;
private double zoom = 1.0;
#endregion
#region Constructors
public PreviewWindow(DesignerViewModel designer)
{
this.designer = designer;
InitializeComponent();
BuildPages();
}
#endregion
#region Methods
private void BuildPages()
{
PagesHost.Children.Clear();
foreach (var page in designer.Pages)
{
var frame = new Border
{
Background = page.PaperBrush,
BorderBrush = System.Windows.Media.Brushes.LightGray,
BorderThickness = new Thickness(1),
Margin = new Thickness(0, 0, 0, 20),
Child = PrintService.BuildPageVisual(page),
Width = page.WidthDip,
Height = page.HeightDip,
};
PagesHost.Children.Add(frame);
}
}
private void OnPrint(object sender, RoutedEventArgs e)
{
try
{
PrintService.Print(designer, designer.Document.Title.Length > 0
? designer.Document.Title
: designer.Document.FormId);
}
catch (Exception ex)
{
MessageBox.Show($"인쇄 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnZoomIn(object sender, RoutedEventArgs e) => ApplyZoom(zoom * 1.15);
private void OnZoomOut(object sender, RoutedEventArgs e) => ApplyZoom(zoom / 1.15);
private void ApplyZoom(double value)
{
zoom = Math.Clamp(value, 0.25, 3.0);
ZoomTransform.ScaleX = zoom;
ZoomTransform.ScaleY = zoom;
ZoomText.Text = $"{zoom * 100:0}%";
}
#endregion
}
@@ -0,0 +1,42 @@
<Window x:Class="SheetMe.Designer.Views.QueryEditorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="쿼리 편집" Width="860" Height="560" MinWidth="640" MinHeight="400"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
<DockPanel Margin="12">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
Text="작성 시점에 &lt;&lt;변수&gt;&gt; 가 실제 값으로 치환되어 실행됩니다. 우측 변수를 더블클릭하면 커서 위치에 삽입됩니다."/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<TextBlock x:Name="LengthText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
<Button Content="확인" Padding="20,5" Click="OnConfirm"/>
<Button Content="취소" Padding="20,5" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="230"/>
</Grid.ColumnDefinitions>
<!-- SQL 편집 영역 -->
<TextBox x:Name="SqlBox" Grid.Column="0"
FontFamily="Consolas, D2Coding, 굴림체" FontSize="13"
AcceptsReturn="True" AcceptsTab="True"
TextWrapping="NoWrap"
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
TextChanged="OnSqlChanged"/>
<GridSplitter Grid.Column="1" HorizontalAlignment="Stretch" Background="{DynamicResource B.Line}"/>
<!-- 치환 변수 목록 -->
<DockPanel Grid.Column="2">
<TextBlock DockPanel.Dock="Top" Text="치환 변수 (더블클릭 삽입)" FontWeight="Bold"
Foreground="{DynamicResource B.Muted}" Margin="4,0,0,6"/>
<ListBox x:Name="VariableList" FontSize="12" MouseDoubleClick="OnInsertVariable"
FontFamily="Consolas, D2Coding, 굴림체"/>
</DockPanel>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,82 @@
using System.Windows;
using System.Windows.Controls;
namespace SheetMe.Designer.Views;
/// <summary>
/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 큰 SQL 편집 영역 + 치환 변수 삽입.
/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery — &lt;&lt;PatientInfo/SheetInfo/WorkInfo.속성&gt;&gt; 리플렉션 치환.
/// 변수 목록 출처: [021]SheetLoadOperatingInfo 의 bzPatientInfo/bzSheetInfo/bzWorkInfo 공개 속성(스칼라만 큐레이션, 2026-07-16).
/// </summary>
public partial class QueryEditorWindow : Window
{
#region Member Fields
/// <summary>치환 변수 목록 — 작성 시점 실제 값으로 치환됨</summary>
private static readonly string[] Variables =
{
"<<PatientInfo.ChtNum>>",
"<<PatientInfo.ComNum>>",
"<<PatientInfo.PatTyp>>",
"<<PatientInfo.OdrNum>>",
"<<PatientInfo.OdrSeq>>",
"<<SheetInfo.ShtCod>>",
"<<SheetInfo.ShtNam>>",
"<<SheetInfo.EmrKey>>",
"<<SheetInfo.EmrGbn>>",
"<<SheetInfo.AdpDtm>>",
"<<SheetInfo.PatTyp>>",
"<<SheetInfo.OdrNum>>",
"<<SheetInfo.OdrSeq>>",
"<<SheetInfo.TemKey>>",
"<<WorkInfo.WrkUid>>",
"<<WorkInfo.WrkNam>>",
"<<WorkInfo.WrkDte>>",
"<<WorkInfo.WrkDtm>>",
"<<WorkInfo.AdpDep>>",
"<<WorkInfo.AdpDtm>>",
};
#endregion
#region Properties
/// <summary>편집 결과 SQL — 확인 시 채워짐</summary>
public string QueryText { get; private set; } = string.Empty;
#endregion
#region Constructors
public QueryEditorWindow(string ownerLabel, string initialQuery)
{
InitializeComponent();
Title = $"쿼리 편집 — {ownerLabel}";
SqlBox.Text = initialQuery;
VariableList.ItemsSource = Variables;
Loaded += (_, _) =>
{
SqlBox.Focus();
SqlBox.CaretIndex = SqlBox.Text.Length;
};
}
#endregion
#region Methods
private void OnSqlChanged(object sender, TextChangedEventArgs e)
=> LengthText.Text = $"{SqlBox.Text.Length:N0}자";
private void OnInsertVariable(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (VariableList.SelectedItem is not string variable)
{
return;
}
var caret = SqlBox.CaretIndex;
SqlBox.Text = SqlBox.Text.Insert(caret, variable);
SqlBox.CaretIndex = caret + variable.Length;
SqlBox.Focus();
}
private void OnConfirm(object sender, RoutedEventArgs e)
{
QueryText = SqlBox.Text;
DialogResult = true;
}
#endregion
}
@@ -0,0 +1,45 @@
<Window x:Class="SheetMe.Designer.Views.RecordWordDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="상용구 관리" Width="560" Height="520" MinWidth="480" MinHeight="380"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
<DockPanel Margin="12">
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<!-- 입력/버튼 -->
<DockPanel DockPanel.Dock="Bottom" Margin="0,8,0,0">
<Button DockPanel.Dock="Right" Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
<Button DockPanel.Dock="Right" Content="추가" Padding="16,4" Click="OnAdd" IsDefault="True"/>
<TextBox x:Name="NewWordBox" Padding="4,3" Margin="0,0,8,0"
VerticalContentAlignment="Center"
ToolTip="새 상용구 입력 후 Enter 또는 추가"/>
</DockPanel>
<!-- 목록 + 우측 조작 -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ListBox x:Name="WordList" Grid.Column="0" FontSize="13"
MouseDoubleClick="OnEdit"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Value}" TextWrapping="Wrap" Margin="2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Column="1" Margin="8,0,0,0">
<Button Content="수정" Padding="10,3" Click="OnEdit"/>
<Button Content="삭제" Padding="10,3" Margin="0,6,0,0" Click="OnRemove"/>
<Separator Margin="0,10"/>
<Button Content="위로 ▲" Padding="10,3" Click="OnMoveUp"/>
<Button Content="아래로 ▼" Padding="10,3" Margin="0,6,0,0" Click="OnMoveDown"/>
</StackPanel>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,154 @@
using System.Collections.ObjectModel;
using System.Windows;
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Views;
/// <summary>
/// 서식별 상용구 관리 대화상자 — E_SHTWRDMST 1단계 문구 추가/수정/삭제/순서.
/// 레거시 런타임 상용구 팝업(MRecordWord)이 그대로 읽어가는 데이터를 관리한다.
/// </summary>
public partial class RecordWordDialogView : Window
{
#region Member Fields
private readonly RecordWordStore store;
private readonly string shtCod;
private readonly ObservableCollection<RecordWordInfo> words = new();
#endregion
#region Constructors
public RecordWordDialogView(RecordWordStore store, string shtCod, string sheetTitle)
{
this.store = store;
this.shtCod = shtCod;
InitializeComponent();
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 작성 화면의 상용구 팝업에 표시되는 문구입니다. " +
"(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)";
WordList.ItemsSource = words;
Loaded += (_, _) => Reload();
NewWordBox.Loaded += (_, _) => NewWordBox.Focus();
}
#endregion
#region Methods
private void Reload()
{
try
{
words.Clear();
foreach (var word in store.List(shtCod))
{
words.Add(word);
}
}
catch (Exception ex)
{
MessageBox.Show($"상용구 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnAdd(object sender, RoutedEventArgs e)
{
var value = NewWordBox.Text.Trim();
if (value.Length == 0)
{
return;
}
try
{
store.Add(shtCod, value, Environment.UserName);
NewWordBox.Clear();
NewWordBox.Focus();
Reload();
WordList.SelectedIndex = words.Count - 1;
}
catch (Exception ex)
{
MessageBox.Show($"추가 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnEdit(object sender, RoutedEventArgs e)
{
if (WordList.SelectedItem is not RecordWordInfo selected)
{
return;
}
var editor = new QueryEditorWindow($"상용구 수정", selected.Value) { Owner = this };
// QueryEditorWindow 재사용(큰 편집 영역) — 변수 삽입은 무시해도 무해
if (editor.ShowDialog() != true)
{
return;
}
var newValue = editor.QueryText.Trim();
if (newValue.Length == 0 || newValue == selected.Value)
{
return;
}
try
{
store.Update(selected.Key, newValue, Environment.UserName);
Reload();
}
catch (Exception ex)
{
MessageBox.Show($"수정 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnRemove(object sender, RoutedEventArgs e)
{
if (WordList.SelectedItem is not RecordWordInfo selected)
{
return;
}
if (MessageBox.Show($"삭제할까요?\n\n{Truncate(selected.Value)}", "상용구 삭제",
MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes)
{
return;
}
try
{
store.Remove(selected.Key);
Reload();
}
catch (Exception ex)
{
MessageBox.Show($"삭제 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnMoveUp(object sender, RoutedEventArgs e) => Move(-1);
private void OnMoveDown(object sender, RoutedEventArgs e) => Move(+1);
private void Move(int delta)
{
var index = WordList.SelectedIndex;
var target = index + delta;
if (index < 0 || target < 0 || target >= words.Count)
{
return;
}
try
{
words.Move(index, target);
store.Reorder(words.Select(w => w.Key).ToList(), Environment.UserName);
WordList.SelectedIndex = target;
}
catch (Exception ex)
{
MessageBox.Show($"순서 변경 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
Reload();
}
}
private static string Truncate(string value)
=> value.Length > 80 ? value[..80] + "…" : value;
#endregion
}
@@ -0,0 +1,32 @@
<Window x:Class="SheetMe.Designer.Views.RegisterSheetDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="신규 서식 등록" Width="420" SizeToContent="Height"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" ResizeMode="NoResize">
<StackPanel Margin="16">
<TextBlock Text="E_ShtMst 에 등록되지 않은 서식입니다. 신규 등록 후 저장합니다."
TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,12"/>
<DockPanel Margin="0,3">
<TextBlock Text="서식 코드" Width="80" VerticalAlignment="Center"/>
<TextBox x:Name="CodeBox" MaxLength="10" Padding="4,3"/>
</DockPanel>
<DockPanel Margin="0,3">
<TextBlock Text="서식 명칭" Width="80" VerticalAlignment="Center"/>
<TextBox x:Name="NameBox" MaxLength="100" Padding="4,3"/>
</DockPanel>
<DockPanel Margin="0,3">
<TextBlock Text="분류 코드" Width="80" VerticalAlignment="Center"/>
<TextBox x:Name="ClassBox" MaxLength="5" Padding="4,3"
ToolTip="선택 입력 — 예: A(초진), J(진단서류) 등 병원 분류 체계"/>
</DockPanel>
<TextBlock Text="기본값: 디자인 서식(ShtTyp='D'), 사용 'Y', 서식생성기 사용 'Y' — 상세 속성은 기록지정보 마스터에서 설정"
TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" FontSize="11" Margin="0,10,0,0"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Content="등록 후 저장" Padding="14,5" Click="OnConfirm" IsDefault="True"/>
<Button Content="취소" Padding="14,5" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,39 @@
using System.Windows;
namespace SheetMe.Designer.Views;
/// <summary>신규 서식(E_ShtMst) 최소 등록 대화상자 — 코드/명칭/분류 입력.</summary>
public partial class RegisterSheetDialogView : Window
{
#region Properties
/// <summary>서식 코드</summary>
public string SheetCode => CodeBox.Text.Trim();
/// <summary>서식 명칭</summary>
public string SheetName => NameBox.Text.Trim();
/// <summary>분류 코드(선택)</summary>
public string? ClassCode => ClassBox.Text.Trim().Length == 0 ? null : ClassBox.Text.Trim();
#endregion
#region Constructors
public RegisterSheetDialogView(string initialCode, string initialName)
{
InitializeComponent();
CodeBox.Text = initialCode;
NameBox.Text = initialName;
}
#endregion
#region Methods
private void OnConfirm(object sender, RoutedEventArgs e)
{
if (SheetCode.Length == 0 || SheetName.Length == 0)
{
MessageBox.Show("서식 코드와 명칭을 입력하세요.", "신규 서식 등록");
return;
}
DialogResult = true;
}
#endregion
}
@@ -0,0 +1,114 @@
<UserControl x:Class="SheetMe.Designer.Views.SelectionOverlayView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:SheetMe.Designer.ViewModels"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance vm:SelectionOverlayViewModel}"
IsHitTestVisible="False">
<!-- 선택/마퀴/가이드 표시 전용 오버레이 — 입력은 월드 파이프라인이 처리(기하 히트테스트) -->
<Canvas>
<!-- 정렬 가이드선 -->
<ItemsControl ItemsSource="{Binding Guides}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Line Stroke="#FF4FA3" StrokeThickness="1" StrokeDashArray="4 3">
<Line.Style>
<Style TargetType="Line">
<Style.Triggers>
<DataTrigger Binding="{Binding IsVertical}" Value="True">
<Setter Property="X1" Value="{Binding Position}"/>
<Setter Property="X2" Value="{Binding Position}"/>
<Setter Property="Y1" Value="-100000"/>
<Setter Property="Y2" Value="100000"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsVertical}" Value="False">
<Setter Property="Y1" Value="{Binding Position}"/>
<Setter Property="Y2" Value="{Binding Position}"/>
<Setter Property="X1" Value="-100000"/>
<Setter Property="X2" Value="100000"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Line.Style>
</Line>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- 선택 박스 -->
<Rectangle Canvas.Left="{Binding SelX}" Canvas.Top="{Binding SelY}"
Width="{Binding SelW}" Height="{Binding SelH}"
Stroke="#1E7BE8" StrokeThickness="1"
Visibility="{Binding HasSelection, Converter={StaticResource BoolToVisibility}}"/>
<!-- 리사이즈 핸들 8개 -->
<ItemsControl ItemsSource="{Binding Handles}"
Visibility="{Binding ShowHandles, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Rectangle Width="8" Height="8" Fill="White" Stroke="#1E7BE8" StrokeThickness="1"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- 마퀴(러버밴드) -->
<Rectangle Canvas.Left="{Binding MarX}" Canvas.Top="{Binding MarY}"
Width="{Binding MarW}" Height="{Binding MarH}"
Stroke="#1E7BE8" StrokeThickness="1" StrokeDashArray="3 2" Fill="#181E7BE8"
Visibility="{Binding HasMarquee, Converter={StaticResource BoolToVisibility}}"/>
<!-- 탭순서 배지 (편집 모드 전용) -->
<ItemsControl ItemsSource="{Binding TabBadges}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border MinWidth="18" Height="18" CornerRadius="9" Padding="4,0"
BorderThickness="1.5" BorderBrush="White">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#9AA6B4"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsAssigned}" Value="True">
<Setter Property="Background" Value="#1E7BE8"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding Text}" Foreground="White" FontSize="10" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Canvas>
</UserControl>
@@ -0,0 +1,12 @@
using System.Windows.Controls;
namespace SheetMe.Designer.Views;
/// <summary>선택/마퀴/가이드 오버레이 — 표시 전용(입력 처리 없음).</summary>
public partial class SelectionOverlayView : UserControl
{
public SelectionOverlayView()
{
InitializeComponent();
}
}
@@ -0,0 +1,47 @@
<Window x:Class="SheetMe.Designer.Views.SheetHistoryDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="서식 수정이력" Width="560" Height="520" MinWidth="480" MinHeight="380"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
<DockPanel Margin="12">
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
<Button Content="열람(새 탭)" Padding="16,4" Click="OnOpen" IsDefault="True"
ToolTip="선택한 버전을 새 탭으로 엽니다 — 열람 후 'DB에 저장'하면 그 내용이 새 활성 버전이 됩니다(복원)"/>
<Button Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<ListView x:Name="VersionList" MouseDoubleClick="OnOpen"
VirtualizingPanel.IsVirtualizing="True">
<ListView.View>
<GridView>
<GridViewColumn Header="버전(SdgKey)" Width="110" DisplayMemberBinding="{Binding SdgKey}"/>
<GridViewColumn Header="수정일시" Width="150" DisplayMemberBinding="{Binding UpdDtmText}"/>
<GridViewColumn Header="수정자" Width="110" DisplayMemberBinding="{Binding UpdUid}"/>
<GridViewColumn Header="상태" Width="80">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding StatusText}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding StatusText}" Value="활성">
<Setter Property="Foreground" Value="{DynamicResource B.Success}"/>
<Setter Property="FontWeight" Value="Bold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Window>
@@ -0,0 +1,77 @@
using System.Windows;
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Views;
/// <summary>이력 목록 표시용 행</summary>
public sealed class VersionRow
{
/// <summary>디자인 버전 키</summary>
public decimal SdgKey { get; init; }
/// <summary>수정일시 표시(yyyy-MM-dd HH:mm)</summary>
public string UpdDtmText { get; init; } = string.Empty;
/// <summary>수정자</summary>
public string UpdUid { get; init; } = string.Empty;
/// <summary>상태 표시(활성/이력)</summary>
public string StatusText { get; init; } = string.Empty;
}
/// <summary>
/// 서식 수정이력 대화상자 — E_SdgMst 버전 목록(레거시 UcSheetHistory 이식).
/// 선택 버전을 새 탭으로 열람하고, 열람본을 'DB에 저장'하면 새 활성 버전이 되어 복원 흐름이 완성된다.
/// </summary>
public partial class SheetHistoryDialogView : Window
{
#region Properties
/// <summary>선택된 버전 키 — 열람 확정 시 채워짐</summary>
public decimal? SelectedSdgKey { get; private set; }
#endregion
#region Constructors
public SheetHistoryDialogView(string shtCod, string sheetTitle, List<DesignVersionInfo> versions)
{
InitializeComponent();
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 저장할 때마다 이전 버전이 이력으로 보존됩니다. " +
"이력 버전을 열람한 뒤 'DB에 저장'하면 해당 내용이 새 활성 버전이 됩니다(복원).";
VersionList.ItemsSource = versions.Select(v => new VersionRow
{
SdgKey = v.SdgKey,
UpdDtmText = FormatDtm(v.UpdDtm),
UpdUid = v.UpdUid,
StatusText = v.Deleted ? "이력" : "활성",
}).ToList();
CountText.Text = $"{versions.Count}개 버전";
if (VersionList.Items.Count > 0)
{
VersionList.SelectedIndex = 0;
}
}
#endregion
#region Methods
private void OnOpen(object sender, RoutedEventArgs e)
{
if (VersionList.SelectedItem is not VersionRow row)
{
return;
}
SelectedSdgKey = row.SdgKey;
DialogResult = true;
}
/// <summary>yyyyMMddHHmm(12) / yyyyMMddHHmmss(14) → 사람이 읽는 형식</summary>
private static string FormatDtm(string dtm)
{
if (dtm.Length >= 12
&& int.TryParse(dtm[..4], out _))
{
var time = $"{dtm[8..10]}:{dtm[10..12]}";
return $"{dtm[..4]}-{dtm[4..6]}-{dtm[6..8]} {time}";
}
return dtm;
}
#endregion
}
@@ -0,0 +1,35 @@
<Window x:Class="SheetMe.Designer.Views.SheetOpenDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="DB에서 서식 열기" Width="560" Height="520"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
<DockPanel Margin="12">
<DockPanel DockPanel.Dock="Top">
<Button DockPanel.Dock="Right" Content="검색" Padding="14,4" Margin="6,0,0,0" Click="OnSearch" IsDefault="True"/>
<TextBox x:Name="SearchBox" Padding="4,3" VerticalContentAlignment="Center"/>
</DockPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
<Button Content="열기" Padding="18,5" Click="OnOpen"/>
<Button Content="취소" Padding="18,5" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<ListView x:Name="SheetList" Margin="0,10,0,0" MouseDoubleClick="OnOpen">
<ListView.View>
<GridView>
<GridViewColumn Header="서식코드" Width="110" DisplayMemberBinding="{Binding ShtCod}"/>
<GridViewColumn Header="서식명" Width="310" DisplayMemberBinding="{Binding Name}"/>
<GridViewColumn Header="디자인" Width="70">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="있음" Foreground="{DynamicResource B.Success}"
Visibility="{Binding HasDesign, Converter={StaticResource BoolToVisibility}}"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Window>
@@ -0,0 +1,64 @@
using System.Windows;
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Views;
/// <summary>
/// DB 서식 열기 대화상자 — 검색/목록/선택.
/// 검색 실행 콜백을 주입받는 얇은 대화상자(모달 결과 = SelectedSheet).
/// </summary>
public partial class SheetOpenDialogView : Window
{
#region Member Fields
private readonly Func<string?, List<SheetSummary>> search;
#endregion
#region Properties
/// <summary>선택된 서식 — 확인 시 채워짐</summary>
public SheetSummary? SelectedSheet { get; private set; }
#endregion
#region Constructors
public SheetOpenDialogView(Func<string?, List<SheetSummary>> search)
{
this.search = search;
InitializeComponent();
Loaded += (_, _) => RunSearch();
}
#endregion
#region Methods
private void OnSearch(object sender, RoutedEventArgs e) => RunSearch();
private void RunSearch()
{
try
{
var result = search(SearchBox.Text);
SheetList.ItemsSource = result;
CountText.Text = $"{result.Count}건";
}
catch (Exception ex)
{
MessageBox.Show($"서식 목록 조회 중 오류가 발생했습니다.\n\n{ex.Message}", "오류",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnOpen(object sender, RoutedEventArgs e)
{
if (SheetList.SelectedItem is not SheetSummary selected)
{
return;
}
if (!selected.HasDesign)
{
MessageBox.Show("선택한 서식에는 저장된 디자인이 없습니다.", "열기",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
SelectedSheet = selected;
DialogResult = true;
}
#endregion
}
@@ -0,0 +1,23 @@
<Window x:Class="SheetMe.Designer.Views.TagPickerDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="태그 선택" Width="480" Height="560"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False">
<DockPanel Margin="12">
<TextBlock x:Name="TitleText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
TextWrapping="Wrap" Margin="0,0,0,8"/>
<TextBox x:Name="SearchBox" DockPanel.Dock="Top" Padding="4,3"
TextChanged="OnSearchChanged" VerticalContentAlignment="Center"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
<Button Content="값 지우기" Padding="12,4" Click="OnClear"/>
<Button Content="선택" Padding="18,4" Margin="8,0,0,0" Click="OnPick" IsDefault="True"/>
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<ListBox x:Name="TagList" Margin="0,8,0,0" MouseDoubleClick="OnPick"
VirtualizingPanel.IsVirtualizing="True" FontSize="13"/>
</DockPanel>
</Window>
@@ -0,0 +1,71 @@
using System.Windows;
using System.Windows.Controls;
namespace SheetMe.Designer.Views;
/// <summary>
/// 태그 선택 대화상자 — 검색(부분 일치, 공백 구분 다중 토큰 AND) + 목록 선택.
/// '값 지우기'는 빈 값으로 확정(속성 제거).
/// </summary>
public partial class TagPickerDialogView : Window
{
#region Member Fields
private readonly IReadOnlyList<string> allTags;
#endregion
#region Properties
/// <summary>선택된 태그 — '값 지우기'면 빈 문자열</summary>
public string? SelectedTag { get; private set; }
#endregion
#region Constructors
public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue)
{
allTags = tags;
InitializeComponent();
TitleText.Text = title;
ApplyFilter(string.Empty);
if (!string.IsNullOrEmpty(currentValue))
{
TagList.SelectedItem = tags.FirstOrDefault(t => t == currentValue);
TagList.ScrollIntoView(TagList.SelectedItem);
}
Loaded += (_, _) => SearchBox.Focus();
}
#endregion
#region Methods
private void OnSearchChanged(object sender, TextChangedEventArgs e)
=> ApplyFilter(SearchBox.Text);
private void ApplyFilter(string keyword)
{
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var filtered = tokens.Length == 0
? allTags
: allTags.Where(t => tokens.All(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))).ToList();
TagList.ItemsSource = filtered;
CountText.Text = $"{filtered.Count}/{allTags.Count}건";
if (filtered.Count > 0)
{
TagList.SelectedIndex = 0;
}
}
private void OnPick(object sender, RoutedEventArgs e)
{
if (TagList.SelectedItem is not string tag)
{
return;
}
SelectedTag = tag;
DialogResult = true;
}
private void OnClear(object sender, RoutedEventArgs e)
{
SelectedTag = string.Empty;
DialogResult = true;
}
#endregion
}
+13
View File
@@ -0,0 +1,13 @@
{
"ConnectionStrings": {
"His": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=__HOST__)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=__SERVICE__)));User Id=__USER__;Password=__PASSWORD__;"
},
"FormStore": {
"SaveMode": "File",
"XmlFolder": "forms"
},
"Designer": {
"GridSize": 4,
"SnapThreshold": 6
}
}