초기 커밋: SheetMe 서식생성기 (P0~P5 완료 상태)
레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너. 기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49. 이 커밋에 함께 포함된 자격증명 분리: - appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환 - 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외, csproj Debug 조건부 복사라 Release 산출물에 실리지 않음) - ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경, 미치환 플레이스홀더는 '미설정'으로 간주 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SheetMe.Data.Config;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// appsettings → DataConfig 바인딩 로더.
|
||||
/// 우선순위: 환경변수 > appsettings.Development.json > appsettings.json.
|
||||
/// 커밋되는 appsettings.json 은 <c>__HOST__</c> 류 플레이스홀더만 담으며, 실접속 정보는
|
||||
/// appsettings.Development.json(개발, Debug 빌드에서만 산출물 복사) 또는 환경변수로 주입한다.
|
||||
/// </summary>
|
||||
public static class ConfigLoader
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>설정 로드 — 설정이 없으면 기본값(File 모드)</summary>
|
||||
public static DataConfig Load()
|
||||
{
|
||||
var config = new DataConfig();
|
||||
var basePath = AppContext.BaseDirectory;
|
||||
|
||||
var root = new ConfigurationBuilder()
|
||||
.SetBasePath(basePath)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Development.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var connectionString = root.GetConnectionString("His") ?? string.Empty;
|
||||
config.ConnectionString = IsPlaceholder(connectionString) ? string.Empty : connectionString;
|
||||
config.Provider = root["His:Provider"] ?? "Oracle";
|
||||
config.SaveMode = root["FormStore:SaveMode"] ?? "File";
|
||||
config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms";
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 커밋본의 미치환 플레이스홀더인지 — 이 경우 '미설정'으로 간주해 DB 기능을 끈다.
|
||||
/// 플레이스홀더를 그대로 접속에 쓰면 무의미한 연결 실패 예외가 사용자에게 노출된다.
|
||||
/// </summary>
|
||||
private static bool IsPlaceholder(string connectionString) =>
|
||||
connectionString.Length == 0 || connectionString.Contains("__", StringComparison.Ordinal);
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>파일 대화상자 래퍼 — ViewModel 에서 View 기술 의존을 격리.</summary>
|
||||
public sealed class DialogService
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>열기 대화상자 — 취소 시 null</summary>
|
||||
public string? ShowOpenFile(string filter, string title)
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Filter = filter,
|
||||
Title = title,
|
||||
};
|
||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
/// <summary>저장 대화상자 — 취소 시 null</summary>
|
||||
public string? ShowSaveFile(string filter, string title, string defaultFileName)
|
||||
{
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Filter = filter,
|
||||
Title = title,
|
||||
FileName = defaultFileName,
|
||||
};
|
||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 모델 ↔ ViewModel 매핑 팩토리.
|
||||
/// z-order 규약: 모델(=XML Object 순서)은 index 0 이 최상위(WinForms Controls 규약),
|
||||
/// WPF Canvas 는 나중에 그린 것이 위 — 따라서 VM 컬렉션은 모델의 역순(그리기 순서)이다.
|
||||
/// </summary>
|
||||
public static class DocumentMapper
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>페이지 모델 → 페이지 VM (컨트롤 트리 포함)</summary>
|
||||
public static PageViewModel CreatePage(FormPage page, int index)
|
||||
{
|
||||
var pageViewModel = new PageViewModel(page, index);
|
||||
|
||||
// 루트 폰트/글자색이 상속의 기점 (실샘플: 루트 Font "굴림, 11.25pt")
|
||||
var rootFontText = page.Root.Props.GetText("Font");
|
||||
var rootFont = rootFontText is not null ? LegacyFormat.ParseFont(rootFontText) : new LegacyFont();
|
||||
var rootForeground = Brushes.Black;
|
||||
|
||||
foreach (var model in PaintOrder(page.Controls))
|
||||
{
|
||||
pageViewModel.Controls.Add(CreateControl(model, rootFont, rootForeground));
|
||||
}
|
||||
return pageViewModel;
|
||||
}
|
||||
|
||||
/// <summary>컨트롤 모델 → 타입별 VM (자식·시각 컨텍스트 포함)</summary>
|
||||
public static ControlViewModel CreateControl(ControlElement model, LegacyFont parentFont, Brush parentForeground)
|
||||
{
|
||||
ControlViewModel viewModel = model.Type switch
|
||||
{
|
||||
"Label" => new LabelViewModel(model),
|
||||
"TextBox" => new TextBoxViewModel(model),
|
||||
"MaskedTextBox" => new MaskedTextBoxViewModel(model),
|
||||
"CheckBox" => new CheckBoxViewModel(model),
|
||||
"RadioButton" => new RadioButtonViewModel(model),
|
||||
"ComboBox" => new ComboBoxViewModel(model),
|
||||
"ListBox" => new ListBoxViewModel(model),
|
||||
"CheckList" => new CheckListViewModel(model),
|
||||
"DateTimePicker" => new DateTimePickerViewModel(model),
|
||||
"Panel" => new PanelViewModel(model),
|
||||
"GroupBox" => new GroupBoxViewModel(model),
|
||||
"Line" => new LineViewModel(model),
|
||||
"PictureBox" => new PictureBoxViewModel(model),
|
||||
"CalcBox" => new CalcBoxViewModel(model),
|
||||
"Button" => new ButtonViewModel(model),
|
||||
"DataTable" => new DataTableViewModel(model),
|
||||
"Spread" => new SpreadViewModel(model),
|
||||
_ => new PlaceholderViewModel(model),
|
||||
};
|
||||
|
||||
viewModel.ResolveVisualContext(parentFont, parentForeground);
|
||||
|
||||
if (viewModel is ContainerViewModel container)
|
||||
{
|
||||
foreach (var child in PaintOrder(model.Children))
|
||||
{
|
||||
var childViewModel = CreateControl(child, viewModel.EffectiveFont, viewModel.Foreground);
|
||||
childViewModel.Parent = container;
|
||||
container.Children.Add(childViewModel);
|
||||
}
|
||||
}
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
/// <summary>모델 순서(0=최상위) → 그리기 순서(마지막=최상위)로 역전</summary>
|
||||
private static IEnumerable<ControlElement> PaintOrder(List<ControlElement> models)
|
||||
=> Enumerable.Reverse(models);
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>컨트롤 Id 생성 — 문서 전체에서 유일한 "TextBox1" 식 이름 부여(레거시 관행).</summary>
|
||||
public static class IdGenerator
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>문서의 사용 중 Id 집합 수집</summary>
|
||||
public static HashSet<string> CollectUsed(FormDocument document)
|
||||
{
|
||||
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var page in document.Pages)
|
||||
{
|
||||
Collect(page.Root, used);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
/// <summary>타입 기반 유일 Id 생성 — 발급한 Id 는 used 집합에 추가된다(연속 발급 안전)</summary>
|
||||
public static string NextId(HashSet<string> used, string type)
|
||||
{
|
||||
for (var i = 1; ; i++)
|
||||
{
|
||||
var candidate = type + i;
|
||||
if (used.Add(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>단건 발급 편의 — 문서 스캔 후 1개 생성</summary>
|
||||
public static string NextId(FormDocument document, string type)
|
||||
=> NextId(CollectUsed(document), type);
|
||||
|
||||
private static void Collect(ControlElement element, HashSet<string> used)
|
||||
{
|
||||
if (element.Id.Length > 0)
|
||||
{
|
||||
used.Add(element.Id);
|
||||
}
|
||||
foreach (var child in element.Children)
|
||||
{
|
||||
Collect(child, used);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>포인터 컨텍스트 — 수정키/클릭 수</summary>
|
||||
public readonly record struct PointerContext(bool Ctrl, bool Shift, bool Alt, int ClickCount);
|
||||
|
||||
/// <summary>커서 종류 — 뷰(Behavior)가 WPF Cursor 로 매핑</summary>
|
||||
public enum CursorKind
|
||||
{
|
||||
Arrow,
|
||||
SizeAll,
|
||||
SizeNWSE,
|
||||
SizeNESW,
|
||||
SizeNS,
|
||||
SizeWE,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 마우스 상호작용 상태머신 — 월드 좌표만 다루는 순수 로직(뷰 비의존).
|
||||
/// 상태: None → PendingMove(임계 대기) → Move / Resize / Marquee.
|
||||
/// 드래그 = Undo 1스텝(첫 실이동 시 스냅샷), 절대좌표 재계산(드리프트 방지).
|
||||
/// </summary>
|
||||
public sealed class InteractionController
|
||||
{
|
||||
#region Member Fields
|
||||
private enum Mode { None, PendingMove, Move, Resize, Marquee }
|
||||
|
||||
private const double DragThreshold = 2;
|
||||
private const double MinSize = 8;
|
||||
|
||||
private readonly DesignerViewModel designer;
|
||||
private Mode mode = Mode.None;
|
||||
private Point downPoint;
|
||||
private ControlViewModel? downControl;
|
||||
private bool toggleOnUp;
|
||||
private bool collapseOnUp;
|
||||
private int resizeHandleIndex = -1;
|
||||
private Rect groupBounds0;
|
||||
private readonly Dictionary<ControlViewModel, Rect> origBounds = new();
|
||||
private bool undoCaptured;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public InteractionController(DesignerViewModel designer)
|
||||
{
|
||||
this.designer = designer;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Pointer
|
||||
/// <summary>마우스 다운(월드 좌표)</summary>
|
||||
public void PointerDown(Point world, PointerContext ctx)
|
||||
{
|
||||
// 탭순서 편집 모드 — 클릭은 순번 지정으로만 동작(드래그/핸들/마퀴 차단)
|
||||
if (designer.IsTabOrderMode)
|
||||
{
|
||||
var tabHit = designer.HitTestControl(world);
|
||||
if (tabHit is not null)
|
||||
{
|
||||
designer.ToggleTabOrderClick(tabHit);
|
||||
}
|
||||
mode = Mode.None;
|
||||
return;
|
||||
}
|
||||
|
||||
toggleOnUp = false;
|
||||
collapseOnUp = false;
|
||||
undoCaptured = false;
|
||||
downPoint = world;
|
||||
|
||||
// 1) 핸들 히트 — 선택 bbox 의 8핸들
|
||||
var handleIndex = HandleAt(world);
|
||||
if (handleIndex >= 0)
|
||||
{
|
||||
mode = Mode.Resize;
|
||||
resizeHandleIndex = handleIndex;
|
||||
CaptureOrigBounds();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 컨트롤 히트 — 그룹 멤버면 그룹 전체 단위로 선택
|
||||
var hit = designer.HitTestControl(world);
|
||||
if (hit is not null)
|
||||
{
|
||||
// 더블클릭 → 인라인 텍스트 편집(드래그 시작 안 함)
|
||||
if (ctx.ClickCount >= 2 && DesignerViewModel.IsTextEditable(hit))
|
||||
{
|
||||
mode = Mode.None;
|
||||
designer.RequestInlineEdit(hit);
|
||||
return;
|
||||
}
|
||||
|
||||
downControl = hit;
|
||||
if (ctx.Ctrl || ctx.Shift)
|
||||
{
|
||||
toggleOnUp = true; // 드래그 없이 업이면 토글
|
||||
}
|
||||
else if (!hit.IsSelected)
|
||||
{
|
||||
designer.Selection.Set(designer.GroupMatesOf(hit), hit);
|
||||
}
|
||||
else if (designer.Selection.Items.Count > designer.GroupMatesOf(hit).Count)
|
||||
{
|
||||
collapseOnUp = true; // 그룹보다 넓은 다중선택에서 클릭만 하면 그룹/단일로 축소
|
||||
}
|
||||
mode = Mode.PendingMove;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 빈 곳 — 마퀴
|
||||
if (!ctx.Ctrl && !ctx.Shift)
|
||||
{
|
||||
designer.Selection.Clear();
|
||||
}
|
||||
mode = Mode.Marquee;
|
||||
designer.Overlay.UpdateMarquee(new Rect(world, world));
|
||||
}
|
||||
|
||||
/// <summary>마우스 이동(월드 좌표)</summary>
|
||||
public void PointerMove(Point world, PointerContext ctx)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case Mode.PendingMove:
|
||||
if (Distance(world, downPoint) > DragThreshold && downControl is not null)
|
||||
{
|
||||
if (downControl.Model.Locked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
mode = Mode.Move;
|
||||
toggleOnUp = false;
|
||||
collapseOnUp = false;
|
||||
BeginMove();
|
||||
DoMove(world, ctx); // 전환 프레임부터 즉시 추종
|
||||
}
|
||||
break;
|
||||
|
||||
case Mode.Move:
|
||||
DoMove(world, ctx);
|
||||
break;
|
||||
|
||||
case Mode.Resize:
|
||||
DoResize(world, ctx);
|
||||
break;
|
||||
|
||||
case Mode.Marquee:
|
||||
designer.Overlay.UpdateMarquee(RectFrom(downPoint, world));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>마우스 업(월드 좌표)</summary>
|
||||
public void PointerUp(Point world, PointerContext ctx)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case Mode.PendingMove:
|
||||
if (toggleOnUp && downControl is not null)
|
||||
{
|
||||
// 그룹 단위 토글
|
||||
var mates = designer.GroupMatesOf(downControl);
|
||||
if (downControl.IsSelected)
|
||||
{
|
||||
designer.Selection.Set(designer.Selection.Items.Except(mates).ToList());
|
||||
}
|
||||
else
|
||||
{
|
||||
designer.Selection.Set(designer.Selection.Items.Union(mates).ToList(), downControl);
|
||||
}
|
||||
}
|
||||
else if (collapseOnUp && downControl is not null)
|
||||
{
|
||||
designer.Selection.Set(designer.GroupMatesOf(downControl), downControl);
|
||||
}
|
||||
break;
|
||||
|
||||
case Mode.Move:
|
||||
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
|
||||
designer.Overlay.SetGuides(null, null);
|
||||
break;
|
||||
|
||||
case Mode.Resize:
|
||||
designer.Overlay.SetGuides(null, null);
|
||||
break;
|
||||
|
||||
case Mode.Marquee:
|
||||
var rect = RectFrom(downPoint, world);
|
||||
designer.Overlay.UpdateMarquee(null);
|
||||
var hits = designer.ControlsIntersecting(rect)
|
||||
.SelectMany(designer.GroupMatesOf)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (ctx.Ctrl || ctx.Shift)
|
||||
{
|
||||
var merged = designer.Selection.Items.Union(hits).ToList();
|
||||
designer.Selection.Set(merged);
|
||||
}
|
||||
else if (hits.Count > 0)
|
||||
{
|
||||
designer.Selection.Set(hits);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
mode = Mode.None;
|
||||
downControl = null;
|
||||
origBounds.Clear();
|
||||
designer.RefreshOverlay();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 진행 중 드래그만 취소(원위치 복원) — 캡처 상실 등 비정상 종료용.
|
||||
/// 유휴 상태에서는 아무것도 하지 않는다(정상 마우스 업 후 캡처 해제가 선택을 지우면 안 됨).
|
||||
/// </summary>
|
||||
public void CancelDrag()
|
||||
{
|
||||
if (mode is Mode.Move or Mode.Resize)
|
||||
{
|
||||
foreach (var (vm, rect) in origBounds)
|
||||
{
|
||||
var offset = designer.ParentWorldOffset(vm);
|
||||
vm.X = rect.X - offset.X;
|
||||
vm.Y = rect.Y - offset.Y;
|
||||
vm.Width = rect.Width;
|
||||
vm.Height = rect.Height;
|
||||
}
|
||||
designer.Overlay.SetGuides(null, null);
|
||||
mode = Mode.None;
|
||||
origBounds.Clear();
|
||||
designer.RefreshOverlay();
|
||||
}
|
||||
else if (mode is Mode.Marquee or Mode.PendingMove)
|
||||
{
|
||||
designer.Overlay.UpdateMarquee(null);
|
||||
mode = Mode.None;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Esc — 탭순서 모드 취소 / 진행 중 드래그 취소 / 유휴 상태면 선택 해제</summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (designer.IsTabOrderMode)
|
||||
{
|
||||
designer.CancelTabOrderMode();
|
||||
return;
|
||||
}
|
||||
if (mode == Mode.None)
|
||||
{
|
||||
designer.Selection.Clear();
|
||||
return;
|
||||
}
|
||||
CancelDrag();
|
||||
}
|
||||
|
||||
/// <summary>커서 판정(호버 피드백)</summary>
|
||||
public CursorKind CursorAt(Point world)
|
||||
{
|
||||
var handle = HandleAt(world);
|
||||
if (handle >= 0)
|
||||
{
|
||||
return handle switch
|
||||
{
|
||||
0 or 4 => CursorKind.SizeNWSE,
|
||||
2 or 6 => CursorKind.SizeNESW,
|
||||
1 or 5 => CursorKind.SizeNS,
|
||||
_ => CursorKind.SizeWE,
|
||||
};
|
||||
}
|
||||
if (mode == Mode.Move)
|
||||
{
|
||||
return CursorKind.SizeAll;
|
||||
}
|
||||
return designer.HitTestControl(world) is not null ? CursorKind.SizeAll : CursorKind.Arrow;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods - Private
|
||||
private void BeginMove()
|
||||
{
|
||||
CaptureOrigBounds();
|
||||
var page = designer.PageOf(designer.Selection.Primary!) ?? designer.Pages.FirstOrDefault();
|
||||
if (page is not null)
|
||||
{
|
||||
designer.Snap.BeginDrag(page, designer.Selection.Items.ToHashSet());
|
||||
}
|
||||
}
|
||||
|
||||
private void DoMove(Point world, PointerContext ctx)
|
||||
{
|
||||
EnsureUndoSnapshot();
|
||||
|
||||
var dx = world.X - downPoint.X;
|
||||
var dy = world.Y - downPoint.Y;
|
||||
|
||||
var snap = designer.Snap.SnapMove(groupBounds0, dx, dy, ctx.Alt);
|
||||
designer.Overlay.SetGuides(snap.GuideX, snap.GuideY);
|
||||
|
||||
// origBounds 는 월드 좌표 — 로컬 = (원본 월드 + 보정 델타) - 부모 체인 오프셋
|
||||
foreach (var (vm, rect) in origBounds)
|
||||
{
|
||||
var offset = designer.ParentWorldOffset(vm);
|
||||
vm.X = Math.Round(rect.X + snap.Dx - offset.X);
|
||||
vm.Y = Math.Round(rect.Y + snap.Dy - offset.Y);
|
||||
}
|
||||
|
||||
designer.Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
private void DoResize(Point world, PointerContext ctx)
|
||||
{
|
||||
EnsureUndoSnapshot();
|
||||
|
||||
var rect = groupBounds0;
|
||||
var left = rect.Left;
|
||||
var top = rect.Top;
|
||||
var right = rect.Right;
|
||||
var bottom = rect.Bottom;
|
||||
|
||||
// 핸들별 이동 모서리 (0=NW,1=N,2=NE,3=E,4=SE,5=S,6=SW,7=W)
|
||||
if (resizeHandleIndex is 0 or 6 or 7)
|
||||
{
|
||||
left = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
|
||||
}
|
||||
if (resizeHandleIndex is 2 or 3 or 4)
|
||||
{
|
||||
right = designer.Snap.SnapEdge(world.X, isXAxis: true, ctx.Alt);
|
||||
}
|
||||
if (resizeHandleIndex is 0 or 1 or 2)
|
||||
{
|
||||
top = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
|
||||
}
|
||||
if (resizeHandleIndex is 4 or 5 or 6)
|
||||
{
|
||||
bottom = designer.Snap.SnapEdge(world.Y, isXAxis: false, ctx.Alt);
|
||||
}
|
||||
|
||||
var newRect = new Rect(
|
||||
Math.Min(left, right - MinSize),
|
||||
Math.Min(top, bottom - MinSize),
|
||||
Math.Max(MinSize, right - left),
|
||||
Math.Max(MinSize, bottom - top));
|
||||
|
||||
// 그룹 비례 스케일 — origBounds 기준 절대 재계산
|
||||
var sx = newRect.Width / Math.Max(1, groupBounds0.Width);
|
||||
var sy = newRect.Height / Math.Max(1, groupBounds0.Height);
|
||||
|
||||
foreach (var (vm, orig) in origBounds)
|
||||
{
|
||||
var offset = designer.ParentWorldOffset(vm);
|
||||
vm.X = Math.Round(newRect.X + (orig.X - groupBounds0.X) * sx - offset.X);
|
||||
vm.Y = Math.Round(newRect.Y + (orig.Y - groupBounds0.Y) * sy - offset.Y);
|
||||
vm.Width = Math.Max(1, Math.Round(orig.Width * sx));
|
||||
vm.Height = Math.Max(1, Math.Round(orig.Height * sy));
|
||||
}
|
||||
|
||||
designer.Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
/// <summary>드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝</summary>
|
||||
private void EnsureUndoSnapshot()
|
||||
{
|
||||
if (!undoCaptured)
|
||||
{
|
||||
designer.Undo.Snapshot();
|
||||
undoCaptured = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>선택 항목의 월드 bbox·개별 원본 bounds 캡처(X 는 페이지 로컬 = 월드 X, Y 는 월드)</summary>
|
||||
private void CaptureOrigBounds()
|
||||
{
|
||||
origBounds.Clear();
|
||||
foreach (var vm in designer.Selection.Items)
|
||||
{
|
||||
origBounds[vm] = designer.WorldBoundsOf(vm);
|
||||
}
|
||||
groupBounds0 = designer.SelectionWorldBounds() ?? Rect.Empty;
|
||||
}
|
||||
|
||||
private int HandleAt(Point world)
|
||||
{
|
||||
var overlay = designer.Overlay;
|
||||
if (!overlay.HasSelection || !overlay.ShowHandles)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
var bbox = new Rect(overlay.SelX, overlay.SelY, overlay.SelW, overlay.SelH);
|
||||
var positions = SelectionOverlayViewModel.HandlePositions(bbox);
|
||||
|
||||
// 반경 내 '최근접' 핸들 선택 — 작은 컨트롤에서 인접 핸들 오인 방지
|
||||
const double hitRadius = 6;
|
||||
var best = -1;
|
||||
var bestDistance = double.MaxValue;
|
||||
for (var i = 0; i < positions.Length; i++)
|
||||
{
|
||||
var dx = Math.Abs(world.X - positions[i].X);
|
||||
var dy = Math.Abs(world.Y - positions[i].Y);
|
||||
if (dx > hitRadius || dy > hitRadius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var distance = dx * dx + dy * dy;
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static double Distance(Point a, Point b)
|
||||
=> Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
|
||||
|
||||
private static Rect RectFrom(Point a, Point b)
|
||||
=> new(Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y));
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 인쇄 서비스 — 페이지당 FixedPage 1:1(DIP)로 조립해 인쇄.
|
||||
/// 캔버스와 동일한 DataTemplate 사전(App 리소스)을 사용하므로 화면=인쇄 렌더가 일치한다.
|
||||
/// 용지 그림자 등 편집 크롬 없이 흰 배경 + 컨트롤만 그린다.
|
||||
/// </summary>
|
||||
public static class PrintService
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary>
|
||||
public static void Print(DesignerViewModel designer, string documentName)
|
||||
{
|
||||
var dialog = new System.Windows.Controls.PrintDialog();
|
||||
if (dialog.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var document = BuildFixedDocument(designer);
|
||||
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
|
||||
}
|
||||
|
||||
/// <summary>페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용)</summary>
|
||||
public static FixedDocument BuildFixedDocument(DesignerViewModel designer)
|
||||
{
|
||||
var document = new FixedDocument();
|
||||
foreach (var page in designer.Pages)
|
||||
{
|
||||
var fixedPage = new FixedPage
|
||||
{
|
||||
Width = page.WidthDip,
|
||||
Height = page.HeightDip,
|
||||
Background = page.PaperBrush,
|
||||
};
|
||||
fixedPage.Children.Add(BuildPageVisual(page));
|
||||
|
||||
var pageContent = new PageContent();
|
||||
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
|
||||
document.Pages.Add(pageContent);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>페이지 컨트롤층 비주얼 — 크롬 없는 Canvas(그리기 순서 = 컬렉션 순서, 미리보기/인쇄 공용)</summary>
|
||||
public static UIElement BuildPageVisual(PageViewModel page)
|
||||
{
|
||||
var canvas = new Canvas
|
||||
{
|
||||
Width = page.WidthDip,
|
||||
Height = page.HeightDip,
|
||||
};
|
||||
TextOptions.SetTextFormattingMode(canvas, TextFormattingMode.Ideal);
|
||||
|
||||
// 종이 위 렌더는 레거시 충실 유지 — 앱 테마의 암시 TextBlock 스타일(다크 밝은 글자) 차단
|
||||
var paperText = new Style(typeof(TextBlock));
|
||||
paperText.Setters.Add(new Setter(TextBlock.ForegroundProperty, Brushes.Black));
|
||||
canvas.Resources.Add(typeof(TextBlock), paperText);
|
||||
|
||||
foreach (var control in page.Controls)
|
||||
{
|
||||
// 숨김 + 데이터소스(MDataTable — 런타임 비가시)는 인쇄/미리보기에서 제외
|
||||
if (control.Model.Hidden || control is DataTableViewModel)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var presenter = new ContentPresenter
|
||||
{
|
||||
Content = control,
|
||||
Width = Math.Max(1, control.Width),
|
||||
Height = Math.Max(1, control.Height),
|
||||
};
|
||||
Canvas.SetLeft(presenter, control.X);
|
||||
Canvas.SetTop(presenter, control.Y);
|
||||
canvas.Children.Add(presenter);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>선택 집합 관리 — 다중선택 + Primary. 변경 시 IsSelected 플래그 동기화 및 Changed 통지.</summary>
|
||||
public sealed class SelectionService
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly List<ControlViewModel> items = new();
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택된 컨트롤 목록</summary>
|
||||
public IReadOnlyList<ControlViewModel> Items => items;
|
||||
|
||||
/// <summary>기준(Primary) 선택</summary>
|
||||
public ControlViewModel? Primary { get; private set; }
|
||||
|
||||
/// <summary>Primary 가 속한 페이지 — 컨트롤 추가 대상 결정용</summary>
|
||||
public PageViewModel? ActivePage { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
/// <summary>선택 변경 통지(경계 이동 포함) — 오버레이 구독</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>선택 '집합' 변경 통지(Set/Toggle/Clear 만) — 인스펙터 재구성 구독(드래그 프레임 제외)</summary>
|
||||
public event Action? SetChanged;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>선택 교체</summary>
|
||||
public void Set(IEnumerable<ControlViewModel> newItems, ControlViewModel? primary = null)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
item.IsSelected = false;
|
||||
}
|
||||
items.Clear();
|
||||
foreach (var item in newItems.Where(i => !i.Model.Locked))
|
||||
{
|
||||
items.Add(item);
|
||||
item.IsSelected = true;
|
||||
}
|
||||
Primary = primary is not null && items.Contains(primary) ? primary : items.FirstOrDefault();
|
||||
Changed?.Invoke();
|
||||
SetChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>단일 선택</summary>
|
||||
public void SetSingle(ControlViewModel item) => Set(new[] { item }, item);
|
||||
|
||||
/// <summary>토글(Ctrl/Shift+클릭)</summary>
|
||||
public void Toggle(ControlViewModel item)
|
||||
{
|
||||
if (items.Contains(item))
|
||||
{
|
||||
items.Remove(item);
|
||||
item.IsSelected = false;
|
||||
if (Primary == item)
|
||||
{
|
||||
Primary = items.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
else if (!item.Model.Locked)
|
||||
{
|
||||
items.Add(item);
|
||||
item.IsSelected = true;
|
||||
Primary = item;
|
||||
}
|
||||
Changed?.Invoke();
|
||||
SetChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>선택 해제</summary>
|
||||
public void Clear() => Set(Array.Empty<ControlViewModel>());
|
||||
|
||||
/// <summary>이동/리사이즈 중 오버레이 갱신 트리거</summary>
|
||||
public void NotifyBoundsChanged() => Changed?.Invoke();
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Windows;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>스냅 결과 — 보정된 이동량과 표시할 가이드 좌표(월드)</summary>
|
||||
public readonly record struct SnapResult(double Dx, double Dy, double? GuideX, double? GuideY);
|
||||
|
||||
/// <summary>
|
||||
/// 그리드/정렬 스냅 순수 계산.
|
||||
/// 드래그 시작 시 후보(비선택 형제의 좌/중/우·상/중/하 + 용지 경계/중앙)를 1회 사전수집해
|
||||
/// 프레임당 재수집을 피한다.
|
||||
/// </summary>
|
||||
public sealed class SnapEngine
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly List<double> candidatesX = new();
|
||||
private readonly List<double> candidatesY = new();
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>그리드 간격(px)</summary>
|
||||
public double GridSize { get; set; } = 4;
|
||||
|
||||
/// <summary>정렬 스냅 허용 거리(px)</summary>
|
||||
public double Tolerance { get; set; } = 6;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>드래그 시작 — 스냅 후보 사전수집(월드 좌표)</summary>
|
||||
public void BeginDrag(PageViewModel page, IReadOnlySet<ControlViewModel> moving)
|
||||
{
|
||||
candidatesX.Clear();
|
||||
candidatesY.Clear();
|
||||
|
||||
// 용지 경계·중앙
|
||||
candidatesX.Add(0);
|
||||
candidatesX.Add(page.WidthDip / 2);
|
||||
candidatesX.Add(page.WidthDip);
|
||||
candidatesY.Add(page.OffsetY);
|
||||
candidatesY.Add(page.OffsetY + page.HeightDip / 2);
|
||||
candidatesY.Add(page.OffsetY + page.HeightDip);
|
||||
|
||||
foreach (var sibling in page.Controls)
|
||||
{
|
||||
if (moving.Contains(sibling) || sibling.Model.Hidden)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
candidatesX.Add(sibling.X);
|
||||
candidatesX.Add(sibling.X + sibling.Width / 2);
|
||||
candidatesX.Add(sibling.X + sibling.Width);
|
||||
|
||||
var top = page.OffsetY + sibling.Y;
|
||||
candidatesY.Add(top);
|
||||
candidatesY.Add(top + sibling.Height / 2);
|
||||
candidatesY.Add(top + sibling.Height);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이동 스냅 — bbox(월드)에 (dx,dy) 적용 시 정렬 후보와 최근접 정합.
|
||||
/// free=true(Alt)면 스냅 없이 원본 이동량 그대로.
|
||||
/// </summary>
|
||||
public SnapResult SnapMove(Rect bbox, double dx, double dy, bool free)
|
||||
{
|
||||
if (free)
|
||||
{
|
||||
return new SnapResult(dx, dy, null, null);
|
||||
}
|
||||
|
||||
var movedLeft = bbox.X + dx;
|
||||
var movedTop = bbox.Y + dy;
|
||||
|
||||
var (adjustX, guideX) = SnapAxis(new[] { movedLeft, movedLeft + bbox.Width / 2, movedLeft + bbox.Width }, candidatesX);
|
||||
var (adjustY, guideY) = SnapAxis(new[] { movedTop, movedTop + bbox.Height / 2, movedTop + bbox.Height }, candidatesY);
|
||||
|
||||
// 정합 실패 축은 그리드 스냅
|
||||
var resultDx = guideX is not null ? dx + adjustX : Math.Round(movedLeft / GridSize) * GridSize - bbox.X;
|
||||
var resultDy = guideY is not null ? dy + adjustY : Math.Round(movedTop / GridSize) * GridSize - bbox.Y;
|
||||
|
||||
return new SnapResult(resultDx, resultDy, guideX, guideY);
|
||||
}
|
||||
|
||||
/// <summary>리사이즈 스냅 — 움직이는 모서리 좌표만 정합(간이: 그리드 우선)</summary>
|
||||
public double SnapEdge(double value, bool isXAxis, bool free)
|
||||
{
|
||||
if (free)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
var candidates = isXAxis ? candidatesX : candidatesY;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (Math.Abs(candidate - value) <= Tolerance)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return Math.Round(value / GridSize) * GridSize;
|
||||
}
|
||||
|
||||
private (double Adjust, double? Guide) SnapAxis(double[] edges, List<double> candidates)
|
||||
{
|
||||
var best = double.MaxValue;
|
||||
double adjust = 0;
|
||||
double? guide = null;
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var distance = candidate - edge;
|
||||
if (Math.Abs(distance) <= Tolerance && Math.Abs(distance) < Math.Abs(best))
|
||||
{
|
||||
best = distance;
|
||||
adjust = distance;
|
||||
guide = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (adjust, guide);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 라이트/다크 테마 전환 — App.Resources 의 토큰 사전(Tokens.Dark↔Light)을 교체하면
|
||||
/// {DynamicResource B.*} 를 참조하는 모든 스타일이 라이브 리스킨된다([200]SheetMe SwapThemeTokens 이식).
|
||||
/// 선택은 %LocalAppData%\SheetMe\theme.json 에 보존.
|
||||
/// </summary>
|
||||
public static class ThemeManager
|
||||
{
|
||||
#region Member Fields
|
||||
private static readonly string SettingsPath =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SheetMe", "theme.json");
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>현재 라이트 테마 여부(기본 다크 — [200]SheetMe 기본값과 동일)</summary>
|
||||
public static bool IsLight { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>시작 시 저장된 테마 적용</summary>
|
||||
public static void LoadSaved()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(SettingsPath) &&
|
||||
JsonDocument.Parse(File.ReadAllText(SettingsPath)).RootElement.TryGetProperty("theme", out var theme) &&
|
||||
theme.GetString() == "light")
|
||||
{
|
||||
Apply(light: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 설정 손상 시 기본(다크) 유지
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>라이트↔다크 토글</summary>
|
||||
public static void Toggle() => Apply(!IsLight);
|
||||
|
||||
/// <summary>테마 적용 — App.Resources 병합 사전에서 토큰 dict 를 찾아 교체</summary>
|
||||
public static void Apply(bool light)
|
||||
{
|
||||
IsLight = light;
|
||||
var dictionaries = Application.Current.Resources.MergedDictionaries;
|
||||
var tokensUri = new Uri($"/Themes/Tokens.{(light ? "Light" : "Dark")}.xaml", UriKind.Relative);
|
||||
for (var i = 0; i < dictionaries.Count; i++)
|
||||
{
|
||||
var source = dictionaries[i].Source?.OriginalString ?? string.Empty;
|
||||
if (source.EndsWith("Tokens.Dark.xaml", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.EndsWith("Tokens.Light.xaml", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
dictionaries[i] = new ResourceDictionary { Source = tokensUri };
|
||||
Save();
|
||||
return;
|
||||
}
|
||||
}
|
||||
dictionaries.Add(new ResourceDictionary { Source = tokensUri });
|
||||
Save();
|
||||
}
|
||||
|
||||
private static void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
|
||||
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(new { theme = IsLight ? "light" : "dark" }));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 저장 실패는 무시(다음 실행 기본 테마)
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 문서 스냅샷(메멘토) Undo/Redo — 직렬화 대신 모델 딥클론(편집 전용 상태 포함) 사용.
|
||||
/// 규약: 변경 '직전' Snapshot() 1회. 드래그는 첫 실이동에서 1회 = 드래그 전체가 1스텝.
|
||||
/// 방향키 넛지는 400ms 코얼레스.
|
||||
/// </summary>
|
||||
public sealed class UndoService
|
||||
{
|
||||
#region Member Fields
|
||||
private const int Capacity = 100;
|
||||
private readonly List<FormDocument> undoStack = new();
|
||||
private readonly List<FormDocument> redoStack = new();
|
||||
private readonly Func<FormDocument> getDocument;
|
||||
private readonly Action<FormDocument> restoreDocument;
|
||||
private DateTime lastNudgeAt = DateTime.MinValue;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>Undo 가능 여부</summary>
|
||||
public bool CanUndo => undoStack.Count > 0;
|
||||
|
||||
/// <summary>Redo 가능 여부</summary>
|
||||
public bool CanRedo => redoStack.Count > 0;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public UndoService(Func<FormDocument> getDocument, Action<FormDocument> restoreDocument)
|
||||
{
|
||||
this.getDocument = getDocument;
|
||||
this.restoreDocument = restoreDocument;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
/// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary>
|
||||
public event Action? Restored;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>변경 직전 스냅샷 적재 — redo 클리어</summary>
|
||||
public void Snapshot()
|
||||
{
|
||||
undoStack.Add(getDocument().Clone());
|
||||
if (undoStack.Count > Capacity)
|
||||
{
|
||||
undoStack.RemoveRange(0, undoStack.Count - Capacity);
|
||||
}
|
||||
redoStack.Clear();
|
||||
lastNudgeAt = DateTime.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary>
|
||||
public void SnapshotForNudge()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if ((now - lastNudgeAt).TotalMilliseconds > 400)
|
||||
{
|
||||
Snapshot();
|
||||
}
|
||||
lastNudgeAt = now;
|
||||
}
|
||||
|
||||
/// <summary>실행 취소</summary>
|
||||
public void Undo()
|
||||
{
|
||||
if (!CanUndo)
|
||||
{
|
||||
return;
|
||||
}
|
||||
redoStack.Add(getDocument().Clone());
|
||||
var snapshot = undoStack[^1];
|
||||
undoStack.RemoveAt(undoStack.Count - 1);
|
||||
restoreDocument(snapshot);
|
||||
Restored?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>다시 실행</summary>
|
||||
public void Redo()
|
||||
{
|
||||
if (!CanRedo)
|
||||
{
|
||||
return;
|
||||
}
|
||||
undoStack.Add(getDocument().Clone());
|
||||
var snapshot = redoStack[^1];
|
||||
redoStack.RemoveAt(redoStack.Count - 1);
|
||||
restoreDocument(snapshot);
|
||||
Restored?.Invoke();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user