Editor/Canvas/CanvasManipulator.cs
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Selection;
using Sandbox;
using Sandbox.UI;
using Length = Grains.RazorDesigner.Document.Length;
namespace Grains.RazorDesigner.Canvas;
public sealed class CanvasManipulator
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private readonly SelectionController _selection;
private readonly OverlayController _overlay;
private readonly System.Func<DesignerScene> _getScene;
private readonly System.Func<float> _getWidgetDpiScale;
private readonly System.Action<ControlRecord> _commit;
private readonly System.Func<(bool Enabled, float StepCss)> _getGridSnap;
private bool _bypassUsedThisDrag;
private bool _dragging;
private CanvasGrab _grab;
private ControlRecord _record;
private Vector2 _anchorWidget; // press point, widget px
private float _origCssLeft, _origCssTop, _origCssW, _origCssH; // resolved CSS-px geometry at drag start
private Rect _pressOuterFb; // the control's margin box (framebuffer px) at drag start
private Rect _pressParentInnerFb; // the parent's content box (framebuffer px) at drag start
private bool _appliedAnything; // true once OnMove has written at least one inline style this drag
private float _finalLeft, _finalTop, _finalW, _finalH; // last applied geometry, committed on release
private bool _flippedThisDrag;
private bool _flipPinnedSize; // true when the flip pinned Width/Height (containers only)
private bool _axisLatched; // true once a Shift-held body drag has picked an axis this drag
private bool _axisIsX; // which axis the body drag is locked to (valid when _axisLatched)
public CanvasManipulator(
SelectionController selection,
OverlayController overlay,
System.Func<DesignerScene> getScene,
System.Func<float> getWidgetDpiScale,
System.Action<ControlRecord> commit,
System.Func<(bool Enabled, float StepCss)> getGridSnap )
{
_selection = selection;
_overlay = overlay;
_getScene = getScene;
_getWidgetDpiScale = getWidgetDpiScale;
_commit = commit;
_getGridSnap = getGridSnap;
Log.Info( $"{LogPrefix} CanvasManipulator ctor" );
}
public bool IsDragging => _dragging;
/// <summary>Returns true if this press starts a drag (caller must then NOT run click-to-select).</summary>
public bool OnPress( Vector2 widgetPx, bool isRightClick )
{
if ( isRightClick ) return false;
var grab = _overlay?.HitTest( widgetPx ) ?? CanvasGrab.None;
if ( grab == CanvasGrab.None ) return false;
var rec = _selection?.Selected;
if ( rec is null || rec.LivePanel is null || !rec.LivePanel.IsValid ) return false;
var scene = _getScene();
if ( scene is null ) return false;
_dragging = true;
_grab = grab;
_record = rec;
_anchorWidget = widgetPx;
_flippedThisDrag = false;
_flipPinnedSize = false;
_appliedAnything = false;
_axisLatched = false;
_axisIsX = false;
_bypassUsedThisDrag = false;
var geo = CanvasGeometry.From( scene, _getWidgetDpiScale() );
var live0 = rec.LivePanel;
var fb = geo.BorderBoxFb( live0 ); // rendered border box (framebuffer px)
var m = live0.Box.Margin; // resolved margins (framebuffer px)
var parent0 = live0.Parent;
_pressOuterFb = geo.MarginBoxFb( live0 ); // margin box — clamp THIS inside the parent's content box
_pressParentInnerFb = (parent0 is { IsValid: true }) ? geo.ContentBoxFb( parent0 ) : geo.RootBoundsFb;
(_origCssLeft, _origCssTop, _origCssW, _origCssH) =
CanvasGeometry.ResolveAbsoluteCss( fb, _pressParentInnerFb, m, geo.Scale );
_finalLeft = _origCssLeft; _finalTop = _origCssTop; _finalW = _origCssW; _finalH = _origCssH;
Log.Info( $"{LogPrefix} CanvasManipulator.OnPress: grab={_grab} on {rec.ClassName} (seed L={_origCssLeft:F0} T={_origCssTop:F0} W={_origCssW:F0} H={_origCssH:F0}; margin L={m.Left:F0} T={m.Top:F0})" );
return true;
}
public void OnMove( Vector2 widgetPx, Sandbox.KeyboardModifiers mods )
{
if ( !_dragging ) return;
if ( _record?.LivePanel is null || !_record.LivePanel.IsValid ) { Abort(); return; }
EnsureAbsoluteIfNeeded();
var scene = _getScene();
if ( scene is null ) return;
var geo = CanvasGeometry.From( scene, _getWidgetDpiScale() );
var scale = geo.Scale;
var d = geo.WidgetDeltaToCss( widgetPx - _anchorWidget ); // CSS-px delta
// working geometry in CSS px, starting from the drag-start resolved values
float left = _origCssLeft, top = _origCssTop, w = _origCssW, h = _origCssH;
const float MinSize = 4f;
switch ( _grab )
{
case CanvasGrab.Body: left += d.x; top += d.y; break;
case CanvasGrab.E: w += d.x; break;
case CanvasGrab.S: h += d.y; break;
case CanvasGrab.SE: w += d.x; h += d.y; break;
case CanvasGrab.W: left += d.x; w -= d.x; break;
case CanvasGrab.N: top += d.y; h -= d.y; break;
case CanvasGrab.NW: left += d.x; w -= d.x; top += d.y; h -= d.y; break;
case CanvasGrab.NE: top += d.y; h -= d.y; w += d.x; break;
case CanvasGrab.SW: left += d.x; w -= d.x; h += d.y; break;
}
bool shift = (mods & Sandbox.KeyboardModifiers.Shift) != 0;
bool alt = (mods & Sandbox.KeyboardModifiers.Alt) != 0;
if ( _grab == CanvasGrab.Body )
{
// alt has no effect on body moves; it only applies to resize grabs below
if ( shift )
{
if ( !_axisLatched )
{
_axisLatched = true;
_axisIsX = System.MathF.Abs( d.x ) >= System.MathF.Abs( d.y );
Log.Info( $"{LogPrefix} CanvasManipulator: axis-lock latched (axisIsX={_axisIsX}) on {_record?.ClassName}" );
}
if ( _axisIsX ) top = _origCssTop; else left = _origCssLeft;
}
else
{
_axisLatched = false; // releasing Shift mid-drag drops the lock
}
}
else // a resize grab
{
// Hoist center coords so they're in scope for both the Alt branch and the post-clamp re-center.
float cx = _origCssLeft + _origCssW * 0.5f;
float cy = _origCssTop + _origCssH * 0.5f;
if ( shift && _origCssW > 0.001f && _origCssH > 0.001f )
{
float ratio = _origCssW / _origCssH; // W per H
bool widthDriven = _grab switch
{
CanvasGrab.E or CanvasGrab.W => true,
CanvasGrab.N or CanvasGrab.S => false,
_ => System.MathF.Abs( w - _origCssW ) >= System.MathF.Abs( h - _origCssH ),
};
if ( widthDriven )
{
float newH = w / ratio;
if ( _grab is CanvasGrab.NW or CanvasGrab.NE or CanvasGrab.N ) top += (h - newH);
h = newH;
}
else
{
float newW = h * ratio;
// newH/newW may go negative if the dragged dimension did; the MinSize clamp below recovers it (bounded overshoot)
if ( _grab is CanvasGrab.NW or CanvasGrab.SW or CanvasGrab.W ) left += (w - newW);
w = newW;
}
}
if ( alt )
{
float newW = _origCssW + (w - _origCssW) * 2f;
float newH = _origCssH + (h - _origCssH) * 2f;
w = newW; h = newH;
left = cx - w * 0.5f;
top = cy - h * 0.5f;
}
if ( w < MinSize ) { if ( _grab is CanvasGrab.W or CanvasGrab.NW or CanvasGrab.SW ) left -= (MinSize - w); w = MinSize; }
if ( h < MinSize ) { if ( _grab is CanvasGrab.N or CanvasGrab.NW or CanvasGrab.NE ) top -= (MinSize - h); h = MinSize; }
// Alt center-resize: the MinSize floor above may have moved an edge; re-pin to the original center.
if ( alt )
{
left = cx - w * 0.5f;
top = cy - h * 0.5f;
}
}
{
bool ctrl = (mods & Sandbox.KeyboardModifiers.Ctrl) != 0;
if ( ctrl ) _bypassUsedThisDrag = true;
var grid = _getGridSnap();
if ( grid.Enabled && !ctrl && grid.StepCss > 0.001f )
{
float origRight = _origCssLeft + _origCssW;
float origBottom = _origCssTop + _origCssH;
float step = grid.StepCss;
static float Snap( float v, float s ) => System.MathF.Round( v / s ) * s;
switch ( _grab )
{
case CanvasGrab.Body:
left = Snap( left, step );
top = Snap( top, step );
break;
case CanvasGrab.E:
{
float r = Snap( left + w, step );
w = System.MathF.Max( MinSize, r - left );
break;
}
case CanvasGrab.W:
{
float l = Snap( left, step );
left = l;
w = System.MathF.Max( MinSize, origRight - l );
break;
}
case CanvasGrab.S:
{
float b = Snap( top + h, step );
h = System.MathF.Max( MinSize, b - top );
break;
}
case CanvasGrab.N:
{
float t = Snap( top, step );
top = t;
h = System.MathF.Max( MinSize, origBottom - t );
break;
}
case CanvasGrab.SE:
{
float r = Snap( left + w, step );
float b = Snap( top + h, step );
w = System.MathF.Max( MinSize, r - left );
h = System.MathF.Max( MinSize, b - top );
break;
}
case CanvasGrab.NW:
{
float l = Snap( left, step );
float t = Snap( top, step );
left = l; top = t;
w = System.MathF.Max( MinSize, origRight - l );
h = System.MathF.Max( MinSize, origBottom - t );
break;
}
case CanvasGrab.NE:
{
float r = Snap( left + w, step );
float t = Snap( top, step );
top = t;
w = System.MathF.Max( MinSize, r - left );
h = System.MathF.Max( MinSize, origBottom - t );
break;
}
case CanvasGrab.SW:
{
float l = Snap( left, step );
float b = Snap( top + h, step );
left = l;
w = System.MathF.Max( MinSize, origRight - l );
h = System.MathF.Max( MinSize, b - top );
break;
}
}
if ( shift && _grab != CanvasGrab.Body && _origCssW > 0.001f && _origCssH > 0.001f )
{
float ratio = _origCssW / _origCssH;
bool widthDriven = _grab switch
{
CanvasGrab.E or CanvasGrab.W => true,
CanvasGrab.N or CanvasGrab.S => false,
_ => System.MathF.Abs( w - _origCssW ) >= System.MathF.Abs( h - _origCssH ),
};
if ( widthDriven )
{
float newH = w / ratio;
if ( _grab is CanvasGrab.NW or CanvasGrab.NE or CanvasGrab.N ) top += (h - newH);
h = System.MathF.Max( MinSize, newH );
}
else
{
float newW = h * ratio;
if ( _grab is CanvasGrab.NW or CanvasGrab.SW or CanvasGrab.W ) left += (w - newW);
w = System.MathF.Max( MinSize, newW );
}
}
// Alt center-symm re-pin: recompute mirror against start center post-snap.
if ( alt && _grab != CanvasGrab.Body )
{
float cx = _origCssLeft + _origCssW * 0.5f;
float cy = _origCssTop + _origCssH * 0.5f;
left = cx - w * 0.5f;
top = cy - h * 0.5f;
}
}
}
var parentPanel = _record.LivePanel.Parent;
var innerFb = (parentPanel is { IsValid: true }) ? parentPanel.Box.RectInner : _pressParentInnerFb;
{
float dL = (left - _origCssLeft) * scale, dT = (top - _origCssTop) * scale;
float dW = (_grab == CanvasGrab.Body) ? 0f : (w - _origCssW) * scale;
float dH = (_grab == CanvasGrab.Body) ? 0f : (h - _origCssH) * scale;
float oL = _pressOuterFb.Left + dL, oT = _pressOuterFb.Top + dT;
float oW = _pressOuterFb.Width + dW, oH = _pressOuterFb.Height + dH;
if ( innerFb.Width >= oW )
{
float cL = System.Math.Clamp( oL, innerFb.Left, innerFb.Left + innerFb.Width - oW );
left += (cL - oL) / scale;
}
if ( innerFb.Height >= oH )
{
float cT = System.Math.Clamp( oT, innerFb.Top, innerFb.Top + innerFb.Height - oH );
top += (cT - oT) / scale;
}
}
var st = _record.LivePanel.Style;
bool isAbsolute = _flippedThisDrag || _record.Position == PositionKind.Absolute;
if ( isAbsolute )
{
st.Left = left;
st.Top = top;
}
if ( _grab != CanvasGrab.Body )
{
st.Width = w;
st.Height = h;
}
st.Dirty();
_finalLeft = left; _finalTop = top; _finalW = w; _finalH = h;
_appliedAnything = true;
// Drag badge: size for a resize grab, signed offset for a body (move) grab.
string badge = _grab == CanvasGrab.Body
? $"Δ {Signed( left - _origCssLeft )}, {Signed( top - _origCssTop )}"
: $"{System.MathF.Round( w )} × {System.MathF.Round( h )}";
_overlay?.SetDimBadge( badge );
}
public void OnRelease( Vector2 widgetPx )
{
if ( !_dragging ) return;
var rec = _record;
var flipped = _flippedThisDrag;
var resized = _grab != CanvasGrab.Body;
var applied = _appliedAnything;
_dragging = false;
_grab = CanvasGrab.None;
_record = null;
_overlay?.SetDimBadge( null );
if ( rec is null ) return;
if ( !applied )
{
// Press + release with no movement (e.g. clicking the already-selected control's body).
Log.Info( $"{LogPrefix} CanvasManipulator.OnRelease: no movement on {rec.ClassName}; nothing to commit" );
return;
}
if ( flipped ) rec.Position = PositionKind.Absolute;
bool isAbsolute = rec.Position == PositionKind.Absolute;
if ( isAbsolute )
{
rec.Left = Length.Px( _finalLeft );
rec.Top = Length.Px( _finalTop );
}
if ( resized )
{
rec.Width = Length.Px( _finalW );
rec.Height = Length.Px( _finalH );
}
else if ( flipped && _flipPinnedSize )
{
rec.Width = Length.Px( _finalW );
rec.Height = Length.Px( _finalH );
}
if ( rec.LivePanel is { IsValid: true } live )
{
var st = live.Style;
st.Left = null;
st.Top = null;
st.Width = null;
st.Height = null;
st.Position = null;
st.Dirty();
}
var snap = _getGridSnap();
if ( snap.Enabled || _bypassUsedThisDrag )
Log.Info( $"{LogPrefix} CanvasManipulator: snap step={snap.StepCss:F0}px enabled={snap.Enabled} bypassUsed={_bypassUsedThisDrag}" );
Log.Info( $"{LogPrefix} CanvasManipulator.OnRelease: committing {rec.ClassName} (L={_finalLeft:F0} T={_finalTop:F0} W={_finalW:F0} H={_finalH:F0} abs={isAbsolute})" );
_commit( rec );
}
private static bool NeedsAbsolute( CanvasGrab g ) =>
g is CanvasGrab.Body or CanvasGrab.NW or CanvasGrab.N or CanvasGrab.NE or CanvasGrab.W or CanvasGrab.SW;
private void EnsureAbsoluteIfNeeded()
{
if ( _flippedThisDrag ) return;
if ( !NeedsAbsolute( _grab ) ) return;
_flippedThisDrag = true;
_flipPinnedSize = ContractScanner.Table.Get( _record.Type ).IsContainer;
if ( _record.Position == PositionKind.Absolute )
return; // already absolute — nothing to flip; the working baseline is its resolved geometry
var st = _record.LivePanel.Style;
st.Position = PositionMode.Absolute;
st.Left = _origCssLeft;
st.Top = _origCssTop;
if ( _flipPinnedSize )
{
st.Width = _origCssW;
st.Height = _origCssH;
}
else
{
_pressOuterFb = new Rect( _pressOuterFb.Left, _pressOuterFb.Top, 0f, 0f );
}
st.Dirty();
Log.Info( $"{LogPrefix} CanvasManipulator: auto-flipped {_record.ClassName} to Position=Absolute (inline, pinSize={_flipPinnedSize})" );
}
// "+24" / "-8" / "0" — signed integer px for the move badge.
private static string Signed( float v )
{
int n = (int)System.MathF.Round( v );
return n > 0 ? $"+{n}" : n.ToString();
}
private void Abort()
{
Log.Warning( $"{LogPrefix} CanvasManipulator: live panel went stale mid-drag; aborting" );
_dragging = false;
_grab = CanvasGrab.None;
_record = null;
_flippedThisDrag = false;
_flipPinnedSize = false;
_appliedAnything = false;
_axisLatched = false;
_axisIsX = false;
_bypassUsedThisDrag = false;
_overlay?.SetDimBadge( null );
}
}