Two event-pairing bugs found by validating every handler instead of trusting the table. 1. Ctrl+right-click opened the context menu in edit mode. Cause: WPF's PopupControlService opens menus on right-button-UP, and only the DOWN event was being handled — so the zoom toggled and the menu appeared under it. Both events are now handled when Ctrl is down. (The creator saw this in edit mode; preview mode had the same latent bug.) 2. The social bar's left-click was dead in preview mode and dragged the layer beneath it in edit mode. Cause: PreviewGrid handles the TUNNELLING PreviewMouseLeftButtonDown, which fires before the bar's bubbling handler — and since preview-mode left-click now toggles zoom, that handler swallowed the bar's click too. The preview handler now steps aside when the point is inside the bar's bounds (SocialBarElement.RenderSize, translated to canvas). Audited and left alone because they are correct: left-down → resize handle / element drag / deselect; move → zoom drag before edit drag (by design); left-up → EndPreviewDrag; right-up → zoom-drag release then the Ctrl swallow. Build Debug 0 warnings, 22 tests pass across ZoomBox, PreviewHistory and SocialBar.
745 lines
30 KiB
C#
745 lines
30 KiB
C#
using System;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Effects;
|
|
using System.Windows.Media.Animation;
|
|
using System.Windows.Shapes;
|
|
using System.Windows.Threading;
|
|
using ytLive.Helpers;
|
|
using ytLive.Models;
|
|
using ytLive.ViewModels;
|
|
|
|
namespace ytLive.Controls;
|
|
|
|
public partial class PreviewPane : UserControl
|
|
{
|
|
private bool _isDraggingOverlay;
|
|
private bool _isResizing;
|
|
private bool _isZoomDragging;
|
|
private Point _grabOffset;
|
|
private double _resizeAspect;
|
|
|
|
private readonly System.Collections.Generic.Dictionary<string, Storyboard> _activeBorderAnimations =
|
|
new();
|
|
|
|
private DispatcherTimer? _socialGlowTimer;
|
|
private double _socialGlowAngle;
|
|
|
|
public PreviewPane()
|
|
{
|
|
InitializeComponent();
|
|
DataContextChanged += PreviewPane_DataContextChanged;
|
|
OverlayElements.ItemContainerGenerator.StatusChanged += (_, _) => UpdateSelectedLayerZOrder();
|
|
}
|
|
|
|
private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
|
{
|
|
if (e.OldValue is MainViewModel oldVm)
|
|
{
|
|
oldVm.PropertyChanged -= ViewModel_PropertyChanged;
|
|
oldVm.PreviewTransition -= PlayPreviewTransition;
|
|
}
|
|
if (e.NewValue is MainViewModel newVm)
|
|
{
|
|
newVm.PropertyChanged += ViewModel_PropertyChanged;
|
|
newVm.PreviewTransition += PlayPreviewTransition;
|
|
UpdateZoomBox();
|
|
UpdateSelectedLayerZOrder();
|
|
}
|
|
}
|
|
|
|
/// <summary>Animates the outgoing picture away over the new scene: a fade for Fade and Move,
|
|
/// a right-to-left shrink for Wipe. Cut plays nothing.</summary>
|
|
private void PlayPreviewTransition(ytLive.Services.TransitionType type, double milliseconds)
|
|
{
|
|
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, null);
|
|
TransitionOverlay.Clip = null;
|
|
if (type == ytLive.Services.TransitionType.Cut || milliseconds <= 0)
|
|
{
|
|
TransitionOverlay.Visibility = Visibility.Collapsed;
|
|
return;
|
|
}
|
|
|
|
var snapshot = new System.Windows.Media.Imaging.RenderTargetBitmap(1920, 1080, 96, 96,
|
|
System.Windows.Media.PixelFormats.Pbgra32);
|
|
snapshot.Render(PreviewCanvas);
|
|
TransitionOverlay.Source = snapshot;
|
|
TransitionOverlay.Opacity = 1;
|
|
TransitionOverlay.Visibility = Visibility.Visible;
|
|
var duration = TimeSpan.FromMilliseconds(milliseconds);
|
|
|
|
if (type == ytLive.Services.TransitionType.Wipe)
|
|
{
|
|
var clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 1920, 1080));
|
|
TransitionOverlay.Clip = clip;
|
|
var reveal = new System.Windows.Media.Animation.RectAnimation(new Rect(0, 0, 0, 1080), duration)
|
|
{
|
|
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
|
|
};
|
|
reveal.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
|
|
clip.BeginAnimation(System.Windows.Media.RectangleGeometry.RectProperty, reveal);
|
|
}
|
|
else
|
|
{
|
|
var fade = new System.Windows.Media.Animation.DoubleAnimation(1, 0, duration)
|
|
{
|
|
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
|
|
};
|
|
fade.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
|
|
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, fade);
|
|
}
|
|
}
|
|
|
|
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(MainViewModel.SelectedElement))
|
|
UpdateSelectedLayerZOrder();
|
|
if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape))
|
|
{
|
|
UpdateZoomBox();
|
|
}
|
|
}
|
|
|
|
private void UpdateSelectedLayerZOrder()
|
|
{
|
|
var selected = (DataContext as MainViewModel)?.SelectedElement;
|
|
if (selected != null && !IsDraggableElement(selected))
|
|
selected = null;
|
|
|
|
for (var i = 0; i < OverlayElements.Items.Count; i++)
|
|
{
|
|
if (OverlayElements.ItemContainerGenerator.ContainerFromIndex(i) is UIElement container)
|
|
Panel.SetZIndex(container, ReferenceEquals(OverlayElements.Items[i], selected) ? 1 : 0);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Static images and the webcam share the move/resize/selection behavior.
|
|
public static bool IsDraggableElement(SceneElement element)
|
|
=> element.IsDraggable;
|
|
|
|
public void UpdateSelectionOverlay(bool selected)
|
|
{
|
|
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
|
}
|
|
|
|
// ─── Clicking inside the preview / pull-out drawer (used by the window's
|
|
// Window_PreviewMouseLeftButtonDown deselection logic) ───
|
|
public bool IsClickInsidePreview(DependencyObject original)
|
|
=> IsDescendantOf(original, PreviewGrid);
|
|
|
|
public bool IsClickInsideDrawer(DependencyObject original)
|
|
=> IsDescendantOf(original, TextPullOutHost);
|
|
|
|
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
|
{
|
|
while (child != null && !ReferenceEquals(child, ancestor))
|
|
{
|
|
child = child is Visual or System.Windows.Media.Media3D.Visual3D
|
|
? VisualTreeHelper.GetParent(child)
|
|
: LogicalTreeHelper.GetParent(child);
|
|
}
|
|
return child != null;
|
|
}
|
|
|
|
// ─── Audio meters + TRAX + socials (ROW 2 of the preview pane) ───
|
|
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (sender is Slider slider)
|
|
SetSliderValueFromClick(slider, e);
|
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
|
vm.SetVolumeAdjusting(true);
|
|
}
|
|
|
|
private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
|
vm.SetVolumeAdjusting(false);
|
|
}
|
|
|
|
private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
|
vm.SetVolumeAdjusting(false);
|
|
}
|
|
|
|
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (sender is Slider slider)
|
|
SetSliderValueFromClick(slider, e);
|
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
|
vm.SetGameVolumeAdjusting(true);
|
|
}
|
|
|
|
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
|
vm.SetGameVolumeAdjusting(false);
|
|
}
|
|
|
|
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
|
vm.SetGameVolumeAdjusting(false);
|
|
}
|
|
|
|
private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
|
|
{
|
|
if (FindParent<System.Windows.Controls.Primitives.Thumb>(e.OriginalSource as Visual) != null)
|
|
return;
|
|
var track = slider.Template.FindName("PART_Track", slider) as System.Windows.Controls.Primitives.Track;
|
|
if (track == null) return;
|
|
var pos = e.GetPosition(track);
|
|
var fraction = Math.Clamp(pos.X / track.RenderSize.Width, 0, 1);
|
|
slider.Value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum);
|
|
e.Handled = true;
|
|
}
|
|
|
|
private static T? FindParent<T>(Visual? visual) where T : Visual
|
|
{
|
|
while (visual != null)
|
|
{
|
|
if (visual is T match) return match;
|
|
visual = VisualTreeHelper.GetParent(visual) as Visual;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
|
{
|
|
vm.ToggleGameMuteCommand.Execute(null);
|
|
}
|
|
}
|
|
|
|
// TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
|
|
// loaded); right-click always opens the picker. The picker is the OS file
|
|
// dialog — the "never the OS viewer" rule covers the preview, not pickers.
|
|
|
|
|
|
// ─── Webcam / background context menus (right-click on overlay elements) ───
|
|
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
|
|
=> (DataContext as MainViewModel)?.ChangeWebcamCommand.Execute(null);
|
|
|
|
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is not MenuItem { DataContext: SceneElement element, Tag: string tag }
|
|
|| !Enum.TryParse<BorderAnimation>(tag, out var animation))
|
|
return;
|
|
|
|
element.BorderAnimation = animation;
|
|
|
|
StopBorderAnimation(element.Id);
|
|
|
|
if (animation == BorderAnimation.None) return;
|
|
|
|
var shape = FindBorderShape(sender as DependencyObject);
|
|
if (shape == null) return;
|
|
|
|
var sb = CreateBorderAnimation(animation, shape);
|
|
if (sb == null) return;
|
|
|
|
sb.Freeze();
|
|
_activeBorderAnimations[element.Id] = sb;
|
|
shape.BeginAnimation(Shape.OpacityProperty, null);
|
|
Storyboard.SetTarget(sb, shape);
|
|
sb.Begin();
|
|
}
|
|
|
|
private void StopBorderAnimation(string elementId)
|
|
{
|
|
if (_activeBorderAnimations.TryGetValue(elementId, out var old))
|
|
{
|
|
old.Stop();
|
|
_activeBorderAnimations.Remove(elementId);
|
|
}
|
|
}
|
|
|
|
private static Shape? FindBorderShape(DependencyObject? start)
|
|
{
|
|
var current = start;
|
|
while (current != null)
|
|
{
|
|
if (current is ContextMenu cm && cm.PlacementTarget is FrameworkElement target)
|
|
{
|
|
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(target); i++)
|
|
{
|
|
var child = VisualTreeHelper.GetChild(target, i);
|
|
if (child is Shape s && (s is Rectangle or Ellipse) && !s.IsHitTestVisible)
|
|
return s;
|
|
}
|
|
return null;
|
|
}
|
|
current = VisualTreeHelper.GetParent(current);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static Storyboard? CreateBorderAnimation(BorderAnimation animation, Shape shape)
|
|
{
|
|
var sb = new Storyboard();
|
|
switch (animation)
|
|
{
|
|
case BorderAnimation.Pulse:
|
|
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.3, TimeSpan.FromSeconds(0.8), true);
|
|
break;
|
|
case BorderAnimation.Glow:
|
|
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.5, TimeSpan.FromSeconds(1.2), true);
|
|
break;
|
|
case BorderAnimation.Shimmer:
|
|
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.15, TimeSpan.FromSeconds(0.5), true);
|
|
break;
|
|
case BorderAnimation.Electricity:
|
|
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.2, TimeSpan.FromSeconds(0.1), true);
|
|
break;
|
|
case BorderAnimation.Sparkles:
|
|
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.4, TimeSpan.FromSeconds(0.3), true);
|
|
break;
|
|
case BorderAnimation.MarchingAnts:
|
|
shape.StrokeDashArray = new DoubleCollection { 4, 2 };
|
|
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -20, TimeSpan.FromSeconds(0.8), false);
|
|
break;
|
|
case BorderAnimation.Chase:
|
|
shape.StrokeDashArray = new DoubleCollection { 8, 4 };
|
|
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -30, TimeSpan.FromSeconds(1.5), false);
|
|
break;
|
|
case BorderAnimation.Rainbow:
|
|
if (shape.Stroke is SolidColorBrush brush)
|
|
{
|
|
var frozen = brush.IsFrozen;
|
|
if (frozen) brush = brush.Clone();
|
|
shape.Stroke = brush;
|
|
var colorAnim = new ColorAnimationUsingKeyFrames
|
|
{
|
|
Duration = new Duration(TimeSpan.FromSeconds(3)),
|
|
RepeatBehavior = RepeatBehavior.Forever
|
|
};
|
|
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.Zero)));
|
|
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Yellow, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0.5))));
|
|
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Green, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1))));
|
|
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.DodgerBlue, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1.5))));
|
|
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.MediumPurple, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2))));
|
|
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2.5))));
|
|
Storyboard.SetTargetProperty(colorAnim, new PropertyPath("(Shape.Stroke).(SolidColorBrush.Color)"));
|
|
sb.Children.Add(colorAnim);
|
|
}
|
|
break;
|
|
default:
|
|
return null;
|
|
}
|
|
return sb.Children.Count > 0 ? sb : null;
|
|
}
|
|
|
|
private static void AddDoubleAnimation(Storyboard sb, DependencyProperty prop,
|
|
double from, double to, Duration duration, bool autoReverse)
|
|
{
|
|
var anim = new DoubleAnimation(from, to, duration)
|
|
{
|
|
AutoReverse = autoReverse,
|
|
RepeatBehavior = RepeatBehavior.Forever
|
|
};
|
|
Storyboard.SetTargetProperty(anim, new PropertyPath(prop));
|
|
sb.Children.Add(anim);
|
|
}
|
|
|
|
/// <summary>The border-colour submenu is BUILT from the palette on open: the list of
|
|
/// offered colours lives once, in <see cref="BorderColorPalette"/> (TASK 63's rule —
|
|
/// a picker never carries its own copy). The generated rows inherit this menu's
|
|
/// DataContext (the <see cref="SceneElement"/>), so the click handler is unchanged.</summary>
|
|
private void WebcamBorderColorMenu_SubmenuOpened(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is not MenuItem { DataContext: SceneElement element } menu) return;
|
|
|
|
menu.Items.Clear();
|
|
var none = new MenuItem { Header = "None", Tag = string.Empty, IsCheckable = true,
|
|
IsChecked = string.IsNullOrEmpty(element.BorderColor) };
|
|
none.Click += WebcamMenu_SetBorderColor;
|
|
menu.Items.Add(none);
|
|
|
|
foreach (var colour in BorderColorPalette.All)
|
|
{
|
|
var row = new MenuItem
|
|
{
|
|
Header = colour.ToString(),
|
|
Tag = colour.Hex,
|
|
IsCheckable = true,
|
|
IsChecked = string.Equals(element.BorderColor, colour.Hex, StringComparison.OrdinalIgnoreCase),
|
|
};
|
|
row.Click += WebcamMenu_SetBorderColor;
|
|
menu.Items.Add(row);
|
|
}
|
|
}
|
|
|
|
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
|
|
element.BorderColor = color;
|
|
}
|
|
|
|
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is MenuItem { DataContext: SceneElement element })
|
|
element.IsVisible = false;
|
|
}
|
|
|
|
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is MenuItem { DataContext: SceneElement element })
|
|
(DataContext as MainViewModel)?.RemoveSourceCommand.Execute(element);
|
|
}
|
|
|
|
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
|
|
// unusable for shaky hands (jitter around the direction deadzone), so the bar
|
|
// rides on {Binding SocialBarTop} alone and a click flips it. ───
|
|
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
(DataContext as MainViewModel)?.ToggleSocialBarPosition();
|
|
e.Handled = true;
|
|
}
|
|
|
|
/// <summary>Is this canvas point inside the social bar? It is a full-width strip at the top or
|
|
/// bottom of the frame and it consumes its own click, so the preview's tunnelling handler has
|
|
/// to step aside (see <c>Preview_MouseLeftButtonDown</c>).</summary>
|
|
private bool IsOverSocialBar(Point canvasPoint)
|
|
{
|
|
if (DataContext is not MainViewModel { SocialBarVisible: true }) return false;
|
|
if (SocialBarElement.ActualHeight <= 0) return false;
|
|
|
|
var topLeft = SocialBarElement.TranslatePoint(new Point(0, 0), CanvasGrid);
|
|
return new Rect(topLeft, SocialBarElement.RenderSize).Contains(canvasPoint);
|
|
}
|
|
|
|
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
|
|
{
|
|
if (SocialBarElement == null) return;
|
|
|
|
// Kick the social bar to full brightness so it's immediately visible
|
|
var effect = SocialBarElement.Effect as DropShadowEffect;
|
|
if (effect != null)
|
|
{
|
|
effect.BlurRadius = 40;
|
|
effect.Opacity = 1.0;
|
|
}
|
|
|
|
// Pulse the glow so it catches the eye
|
|
_socialGlowAngle = 0;
|
|
_socialGlowTimer?.Stop();
|
|
_socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
|
|
_socialGlowTimer.Tick += SocialGlowTick;
|
|
_socialGlowTimer.Start();
|
|
}
|
|
|
|
private void SocialsButton_MouseLeave(object sender, MouseEventArgs e)
|
|
{
|
|
_socialGlowTimer?.Stop();
|
|
_socialGlowTimer = null;
|
|
|
|
if (SocialBarElement?.Effect is DropShadowEffect effect)
|
|
{
|
|
effect.BlurRadius = 18;
|
|
effect.Opacity = 0.9;
|
|
}
|
|
}
|
|
|
|
private void SocialGlowTick(object? sender, EventArgs e)
|
|
{
|
|
if (SocialBarElement?.Effect is not DropShadowEffect effect) return;
|
|
|
|
_socialGlowAngle += 0.18;
|
|
if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2;
|
|
|
|
// Smooth sine pulse: BlurRadius 24→48, Opacity 0.7→1.0
|
|
double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1
|
|
effect.BlurRadius = 24 + t * 24;
|
|
effect.Opacity = 0.7 + t * 0.3;
|
|
}
|
|
|
|
// ─── Preview: element selection / move / resize / hit-testing ───
|
|
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
var vm = DataContext as MainViewModel;
|
|
if (vm is null) return;
|
|
|
|
var grid = (Grid)sender;
|
|
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
|
var p = toCanvas.Transform(e.GetPosition(grid));
|
|
|
|
// The social bar owns its own left-click (toggle top ⇄ bottom). This handler is the
|
|
// TUNNELLING preview event, so it fires before the bar's bubbling handler: without this
|
|
// guard the bar would be unclickable in preview mode (zoom would win) and, in edit mode,
|
|
// it would drag whatever layer sat underneath it.
|
|
if (IsOverSocialBar(p)) return;
|
|
|
|
// Preview mode (EDIT off): the left button drives the zoom box — the common gesture in
|
|
// the mode where nothing is editable. Dragging it afterwards is handled by MouseMove.
|
|
if (!vm.IsEditMode)
|
|
{
|
|
vm.ToggleZoom();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
var selected = vm.SelectedElement;
|
|
|
|
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
|
|
{
|
|
vm.PushPreviewUndo();
|
|
_isResizing = true;
|
|
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
|
grid.CaptureMouse();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
var hit = HitElementAt(vm, p);
|
|
if (hit != null)
|
|
{
|
|
vm.SelectedElement = hit;
|
|
// One snapshot per gesture: pushing inside the move loop would fill the stack with
|
|
// a hundred one-pixel steps.
|
|
vm.PushPreviewUndo();
|
|
_isDraggingOverlay = true;
|
|
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
|
grid.CaptureMouse();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
vm.SelectedElement = null;
|
|
}
|
|
|
|
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
|
{
|
|
var vm = DataContext as MainViewModel;
|
|
if (vm is null) return;
|
|
var grid = (Grid)sender;
|
|
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
|
var p = toCanvas.Transform(e.GetPosition(grid));
|
|
|
|
if (e.LeftButton == MouseButtonState.Pressed && vm.IsZoomActive)
|
|
{
|
|
if (!_isZoomDragging)
|
|
{
|
|
vm.StartZoomMove(p.X, p.Y);
|
|
_isZoomDragging = true;
|
|
grid.CaptureMouse();
|
|
}
|
|
else
|
|
{
|
|
vm.UpdateZoomMove(p.X, p.Y);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!vm.IsEditMode) return;
|
|
if (!_isDraggingOverlay && !_isResizing) return;
|
|
|
|
var selected = vm.SelectedElement;
|
|
if (selected == null)
|
|
{
|
|
EndPreviewDrag(grid);
|
|
return;
|
|
}
|
|
|
|
if (_isDraggingOverlay)
|
|
{
|
|
// TASK 57: under the vertical tier the camera snaps into the
|
|
// cropped band (one of six spots) instead of free-floating over
|
|
// the master — outside the band it is not in the stream at all.
|
|
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
|
|
{
|
|
vm.MoveWebcamInVerticalTier(verticalCam, p.X - _grabOffset.X, p.Y - _grabOffset.Y);
|
|
}
|
|
else
|
|
{
|
|
var minX = -(selected.Width - 20);
|
|
var maxX = 1920 - 20;
|
|
var minY = -(selected.Height - 20);
|
|
var maxY = 1080 - 20;
|
|
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
|
|
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
|
|
}
|
|
}
|
|
else if (_isResizing)
|
|
{
|
|
var isWebcam = selected is WebcamSceneConfig;
|
|
var isRound = selected.ClipShape == ClipShape.Round;
|
|
var proportional = isRound || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
|
|
var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(vm.StagedScene?.Name) : 1920;
|
|
var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(vm.StagedScene?.Name) : 1080;
|
|
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
|
|
var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
|
|
if (proportional && newH > maxH)
|
|
{
|
|
newH = maxH;
|
|
newW = newH * _resizeAspect;
|
|
}
|
|
selected.Width = newW;
|
|
selected.Height = newH;
|
|
|
|
// TASK 57: inside the vertical band the size snaps (the Chat
|
|
// camera has two sizes: the small tile or one whole area) and the
|
|
// camera ends up on its spot.
|
|
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
|
|
vm.ResizeWebcamInVerticalTier(verticalCam, newW, newH);
|
|
}
|
|
}
|
|
|
|
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (_isZoomDragging && DataContext is MainViewModel vm)
|
|
{
|
|
vm.EndZoomMove();
|
|
_isZoomDragging = false;
|
|
((Grid)sender).ReleaseMouseCapture();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
EndPreviewDrag((Grid)sender);
|
|
}
|
|
|
|
private void Preview_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
// Right-click = the context menu, in both modes (creator ruling 2026-11-01).
|
|
// Ctrl+right-click = the zoom box, EDIT MODE ONLY: the creator asked for no two-handed
|
|
// chords while live or recording, so the preview swallows Ctrl+right-click instead of
|
|
// letting it fall through to the menu. Returning without Handled is what lets WPF's
|
|
// ContextMenuService open the menu under the cursor.
|
|
var vm = DataContext as MainViewModel;
|
|
if (vm is null) return;
|
|
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) return;
|
|
|
|
if (!vm.IsEditMode)
|
|
{
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
vm.ToggleZoom();
|
|
e.Handled = true;
|
|
}
|
|
|
|
private void Preview_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (_isZoomDragging)
|
|
{
|
|
var grid = (Grid)sender;
|
|
grid.ReleaseMouseCapture();
|
|
_isZoomDragging = false;
|
|
if (DataContext is MainViewModel vm) vm.EndZoomMove();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
// WPF's PopupControlService opens a context menu on right-button-UP, not down — so
|
|
// handling the Down event alone still let a menu appear under a Ctrl+right-click (found
|
|
// by the creator in edit mode, 2026-11-01). Swallow the Up event too, in both modes:
|
|
// Down decides the action, Up must not undo that decision.
|
|
if ((Keyboard.Modifiers & ModifierKeys.Control) != 0)
|
|
e.Handled = true;
|
|
}
|
|
|
|
private void EndPreviewDrag(Grid grid)
|
|
{
|
|
if (_isDraggingOverlay || _isResizing || _isZoomDragging)
|
|
grid.ReleaseMouseCapture();
|
|
_isDraggingOverlay = false;
|
|
_isResizing = false;
|
|
_isZoomDragging = false;
|
|
if (DataContext is MainViewModel vm) vm.EndZoomMove();
|
|
}
|
|
|
|
private bool HitHandle(Point mouseScreen, SceneElement element)
|
|
{
|
|
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
|
|
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
|
|
}
|
|
|
|
internal SceneElement? HitElementAt(MainViewModel vm, Point p)
|
|
{
|
|
var scene = vm.StagedScene;
|
|
if (scene == null) return null;
|
|
|
|
// A layer selected from the list temporarily wins both preview stacking and hit-testing;
|
|
// the scene's persistent element order remains unchanged.
|
|
if (vm.SelectedElement is { } selected && IsHitTestable(selected) && Contains(selected, p))
|
|
return selected;
|
|
|
|
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
|
{
|
|
var element = scene.Elements[i];
|
|
if (IsHitTestable(element) && Contains(element, p))
|
|
return element;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static bool IsHitTestable(SceneElement element)
|
|
=> IsDraggableElement(element)
|
|
&& element is not Source { IsEnabled: false }
|
|
&& element is not WebcamSceneConfig { IsVisible: false };
|
|
|
|
private static bool Contains(SceneElement element, Point point)
|
|
=> point.X >= element.X && point.X <= element.X + element.Width
|
|
&& point.Y >= element.Y && point.Y <= element.Y + element.Height;
|
|
|
|
private void UpdateZoomBox()
|
|
{
|
|
if (DataContext is not MainViewModel vm) return;
|
|
if (vm.IsZoomActive && ZoomVisualBrush != null)
|
|
{
|
|
// The preview mirrors the output zoom layer: sample the WHOLE composed
|
|
// preview (CanvasGrid = backdrop + every element layer — the same all-layer
|
|
// source the output compositor magnifies) at the FIXED capture window (the
|
|
// box/2 region centred on the click) → 2x via Stretch=Fill. Moving the box
|
|
// only moves the ZoomBox element; the Viewbox stays glued to the capture.
|
|
ZoomVisualBrush.Visual = CanvasGrid;
|
|
ZoomVisualBrush.Viewbox = new Rect(
|
|
vm.ZoomState.CaptureX,
|
|
vm.ZoomState.CaptureY,
|
|
vm.ZoomState.Width / 2,
|
|
vm.ZoomState.Height / 2);
|
|
ZoomVisualBrush.ViewboxUnits = BrushMappingMode.Absolute;
|
|
if (vm.ZoomShape == Models.ZoomShape.Circle)
|
|
{
|
|
ZoomCircleOutline.Visibility = Visibility.Visible;
|
|
ZoomRectOutline.Visibility = Visibility.Collapsed;
|
|
ZoomBox.Clip = new EllipseGeometry(new Rect(0, 0, vm.ZoomState.Width, vm.ZoomState.Height));
|
|
}
|
|
else
|
|
{
|
|
ZoomCircleOutline.Visibility = Visibility.Collapsed;
|
|
ZoomRectOutline.Visibility = Visibility.Visible;
|
|
ZoomBox.Clip = null;
|
|
}
|
|
}
|
|
else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, CanvasGrid))
|
|
{
|
|
ZoomVisualBrush.Visual = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Rail settings tab (creator ruling 2026-11-01): left-click opens the same app menu
|
|
/// the top-bar gear used to own, so Bug / Feature / About keep their only entry point.</summary>
|
|
private void SettingsTab_Click(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (sender is not FrameworkElement { ContextMenu: { } menu } anchor) return;
|
|
menu.PlacementTarget = anchor;
|
|
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Left;
|
|
menu.IsOpen = true;
|
|
e.Handled = true;
|
|
}
|
|
|
|
/// <summary>Right-click goes straight to App Settings — the same shortcut the old gear had.</summary>
|
|
private void SettingsTab_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
e.Handled = true;
|
|
if (DataContext is MainViewModel { OpenSettingsCommand: { } open } && open.CanExecute(null))
|
|
open.Execute(null);
|
|
}
|
|
}
|