Files
LlamaCasty/Controls/PreviewPane.xaml.cs
T
gramps 8415bb5f37 design: preview pane to tokens (surface 4), and one palette list instead of two
110 hardcoded colours gone from the preview — the biggest surface in the app. Nicknames:
- the webcam selection ring is the interactive accent (was the retired brand red)
- the mute chips are `attention` (a muted mic is a caution, not a live state) with a white play
  glyph; the live badge is `primary`
- YPP progress bars/track, TRAX level zones, the error line, the dim strips and the poster veil
  all resolve from tokens (`positive`, `secondary-container`, `error-container`, `scrim-dark`,
  `scrim-dark-strong`)

Plus the duplication this sweep exposed: the webcam's right-click "Border Color" submenu carried
**seven hand-written colours**, a second source of truth beside the properties drop-down that
TASK 63 had already unified onto `Models/BorderColorPalette.cs` — and the two lists disagreed
(7 vs 14). The submenu is now built from the palette when it opens, so every offered colour is
the same colour everywhere, with the palette's name beside it. The click handler is unchanged;
generated rows inherit the menu's SceneElement DataContext.

Guard list extended: Controls/PreviewPane.xaml.
2026-10-30 08:28:21 -07:00

705 lines
27 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 MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: MainViewModel vm })
{
vm.ToggleMicMuteCommand.Execute(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.
private void TraxButton_Click(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
vm.TraxLeftClick();
}
private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (DataContext is ViewModels.MainViewModel vm)
vm.TraxRightClick();
e.Handled = true; // the button's right-click context menu must not fire
}
// ─── 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;
}
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 || !vm.IsEditMode) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
var selected = vm.SelectedElement;
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
{
_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;
_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)
{
var vm = DataContext as MainViewModel;
if (vm == null) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
if ((vm.IsLive || vm.IsRecording) && !vm.IsEditMode)
{
if (vm.IsZoomActive)
{
vm.DismissZoom();
e.Handled = true;
return;
}
vm.ActivateZoom(p.X, p.Y);
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;
}
}
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;
}
}
}