UI/GifTimeline.razor
@using System
@using Sandbox
@using Sandbox.UI
@inherits Panel
@namespace BlockParty
@attribute [StyleSheet( "GifTimeline.razor.scss" )]
@* Two-headed replay timeline for the GIF-export overlay: a START head and an END head with the
exported span highlighted between them. Dragging a head SEEKS the replay to that head's frame so the
shrunk preview above shows it, then reports the new span back to the overlay via OnChanged.
Drag handling mirrors ReplayScrubBar (the host stylesheet doesn't cascade into a child component, so
this self-styles and opts pointer-events back in): mouse-down grabs the nearer head, moves seek live,
and a Tick()/HasActive fallback ends the drag if the mouse is released off the (small) bar. *@
<root class="gif-timeline">
<div class="bar">
<div class="region" style="left: @(Css( StartFrac ))%; width: @(Css( EndFrac - StartFrac ))%;"></div>
</div>
<div class="head start" style="left: @(Css( StartFrac ))%;"></div>
<div class="head end" style="left: @(Css( EndFrac ))%;"></div>
</root>
@code
{
[Parameter] public int Length { get; set; }
[Parameter] public int StartTick { get; set; }
[Parameter] public int EndTick { get; set; }
// (startTick, endTick, draggingEndHead) — draggingEndHead tells the overlay which frame is previewed.
[Parameter] public Action<int, int, bool> OnChanged { get; set; }
bool _dragging;
bool _draggingEnd;
// A backward move is deferred to release: re-simming the replay backwards rebuilds the stage, so doing
// it every mouse-move would be a rebuild storm. The head still tracks the cursor; we seek once on
// release. (Same forward-live / backward-deferred rule as ReplayScrubBar.)
bool _pendingBackward;
int Last => Math.Max( 0, Length - 1 );
float StartFrac => Last == 0 ? 0f : (float)StartTick / Last;
float EndFrac => Last == 0 ? 0f : (float)EndTick / Last;
// Plain decimal, invariant culture (a comma-decimal locale or scientific notation breaks the parser).
static string Css( float fraction )
=> (Math.Clamp( fraction, 0f, 1f ) * 100f).ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );
float ScreenPosToFraction( Vector2 pos ) => MathX.LerpInverse( pos.x, Box.Left, Box.Right, true );
int FractionToTick( float f ) => Math.Clamp( (int)MathF.Round( Math.Clamp( f, 0f, 1f ) * Last ), 0, Last );
protected override void OnMouseDown( MousePanelEvent e )
{
base.OnMouseDown( e );
if ( e.MouseButton != MouseButtons.Left ) return; // left-only, like CustomPatternGrid
if ( GameManager.Instance is null )
return;
// Grab whichever head is nearer the cursor (ties go to the end head so a fresh full-range overlay
// can immediately drag the end inward).
float f = ScreenPosToFraction( Mouse.Position );
_draggingEnd = MathF.Abs( f - EndFrac ) <= MathF.Abs( f - StartFrac );
_dragging = true;
_pendingBackward = false;
GameManager.Instance.BeginReplayScrub();
DragTo( f );
e.StopPropagation();
}
// Drag: only while the LEFT press that armed _dragging still holds this panel active. Move
// events are buttonless (engine creates them with "none"), so the button check lives in
// OnMouseDown and _dragging carries it — a right press also sets Active and would drag otherwise.
protected override void OnMouseMove( MousePanelEvent e )
{
base.OnMouseMove( e );
if ( !_dragging || !HasActive )
return;
DragTo( ScreenPosToFraction( Mouse.Position ) );
e.StopPropagation();
}
protected override void OnMouseUp( MousePanelEvent e )
{
base.OnMouseUp( e );
if ( e.MouseButton != MouseButtons.Left || !_dragging )
return;
EndDrag();
e.StopPropagation();
}
// Release fallback (mirrors ReplayScrubBar): if the mouse-up wasn't delivered to this small bar, the
// active pseudo-class clears anyway — once it has and we were dragging, end the drag.
public override void Tick()
{
base.Tick();
if ( _dragging && !HasActive )
EndDrag();
}
void DragTo( float f )
{
var mgr = GameManager.Instance;
int tick = FractionToTick( f );
if ( _draggingEnd )
EndTick = Math.Max( tick, StartTick ); // end can't cross before start
else
StartTick = Math.Min( tick, EndTick ); // start can't cross past end
// The head always tracks the cursor (so the highlighted span updates live). Only the SEEK that
// drives the preview is gated: forward seeks are cheap/incremental, backward ones rebuild the run
// and are deferred to release.
int headTick = _draggingEnd ? EndTick : StartTick;
if ( mgr is not null && headTick >= mgr.ReplayFrame )
{
mgr.SeekReplayToFrame( headTick, playSfx: false );
_pendingBackward = false;
}
else
{
_pendingBackward = true;
}
OnChanged?.Invoke( StartTick, EndTick, _draggingEnd );
}
void EndDrag()
{
_dragging = false;
var mgr = GameManager.Instance;
// Commit a deferred backward seek to wherever the head was dropped (one rebuild, not per-move).
if ( _pendingBackward && mgr is not null )
mgr.SeekReplayToFrame( _draggingEnd ? EndTick : StartTick, playSfx: false );
_pendingBackward = false;
mgr?.EndReplayScrub();
}
protected override int BuildHash()
=> System.HashCode.Combine( StartTick, EndTick, Length, _dragging, _draggingEnd );
}