레거시 대비 전수조사에서 나온 출시 차단 9건 중, 하루 안에 끝나는 국소 수정 7건. 저장 데이터를 망가뜨리는 ③④ 를 먼저 고쳤다. ③ 페이지 복제·추가가 루트 이름을 중복 발급했다 Clone 이 Root.Id 까지 베끼는데 자식만 재발급해서 한 서식에 MDesignerHost1 이 둘 생겼다. AddPage 도 '페이지 수 + 1' 로 지어서 루트가 1·3 인 2장짜리 서식에서 곧바로 3 이 겹쳤다. E_SctMst 동기화는 SctObjNam 사전이라 둘째 루트가 첫째 루트 행에 매핑되고, 그 페이지 자식들의 SctParObj 가 없는 ObjID 를 가리킨 채 남는다. → 두 경로 모두 RenamePageRoot 로 이름과 Name 속성을 함께 새로 받는다. ④ 이름 채번이 빈 번호를 메웠다 TextBox3 을 지우고 새로 놓으면 다시 TextBox3 이 나왔다. 이름은 저장된 답변 (E_SctDta.SctObjNam)이 붙는 열쇠라서, 지운 칸의 과거 입력이 새 칸에 그대로 나타난다. 액션 대상과 CalcBox 수식도 이름으로 서로를 가리키므로 조용히 오배선된다. → 레거시 clsNameCreationService 규약대로 '사용 중 최대번호 + 1'. 숫자가 아닌 접미사(TextBoxTotal)는 번호로 보지 않는다. ① 탭순서 적용이 문서 전체를 밀었다 한 페이지를 편집해도 전 페이지 컨트롤이 999999+i 로 초기화됐다. 레거시는 페이지마다 별도 디자인 서피스라 다른 페이지는 건드리지 않는다. → 초기화 범위를 이번에 클릭한 페이지로 한정. ② 이력본이 열린 상태에서 현재본을 열면 이력 탭이 활성화됐다 FindOpenDbDocument 가 ShtCod 만 보고 있었다. 사용자는 현재본을 고친다고 믿은 채 읽기 전용 스냅샷을 편집하게 된다. → HistorySdgKey is null 조건 추가. ⑤ 레거시에 없는 태그 3종을 고를 수 있었다 PAT_BMI · PAT_IBW · PAT_IBW_퍼센트 는 bzDataInterface.vb:922/957/970 에서 주석 처리된 채로 남아 있어 런타임에 해석되지 않는다. 고르면 배포 후에야 빈칸으로 드러난다. → 카탈로그에서 제거(값은 자유 입력이라 기존 서식에는 영향 없다). ⑥ 신규 서식 등록이 ShtHspYon·ShtExpDte 를 비웠다 --db-shtmst 진단을 신설해 실측한 결과 운영 E_ShtMst 1,737행 중 두 컬럼이 NULL 인 행은 0건이다. ShtExpDte 는 dtEMRLoader.vb:1229 가 NVL(ShtExpDte,' ')='29991231' 로 못박아 거르고, 여러 조회가 sysdate BETWEEN ShtAdpDte AND ShtExpDte 를 쓴다 — NULL 이면 BETWEEN 이 거짓이라 등록은 성공했는데 EMR 어디에서도 안 보이는 서식이 된다(29991231 이 1,718행 98.9%). ShtHspYon 은 기록지정보·자동생성 목록이 'Y' 로 거르며, 디자이너가 만든 서식 169건 중 160건이 'Y'. → 두 값을 INSERT 에 넣는다. ⑦ 빈 선택 상태의 Ctrl+드래그가 예외로 죽었다 Ctrl/Shift 클릭은 선택을 바꾸지 않고 업에서 토글하므로 주선택이 빈 채 드래그가 시작될 수 있는데, BeginMove 가 Selection.Primary! 를 그대로 넘겨 RootAncestorOf 가 즉시 역참조했다. → 앵커를 Primary ?? 첫 선택으로 완화. 편집 스모크 9건 추가(루트 이름 유일 2 · 채번 4 · 탭순서 페이지 격리 1 · 빈 선택 드래그 1 · 태그 3종 부재 1). 채번 검사는 '가운데 번호'를 지운다 — 최대 번호를 지우면 최대값 자체가 내려가므로 같은 이름이 다시 나오는 것이 레거시와 같은 정상 동작이다. 회귀: 테스트 124/124, 편집 스모크 169건 실패 0, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일(HEAD 대비 md5 일치). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
426 lines
14 KiB
C#
426 lines
14 KiB
C#
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();
|
|
// Primary 는 없을 수 있다(선택 항목은 있는데 주선택이 비는 경로가 있다) —
|
|
// ?! 로 넘기면 PageOf → RootAncestorOf 가 즉시 역참조해 이동 시작이 예외로 죽는다
|
|
var anchor = designer.Selection.Primary ?? designer.Selection.Items.FirstOrDefault();
|
|
var page = (anchor is null ? null : designer.PageOf(anchor)) ?? 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
|
|
}
|