Files
SheetMe/src/SheetMe.Designer/Services/InteractionController.cs
T
MsystechandClaude Opus 5 8eeff42a72 전수조사 1단계 — 저장된 서식을 오염시키는 결함 7건 수정
레거시 대비 전수조사에서 나온 출시 차단 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>
2026-08-13 12:08:06 +09:00

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
}