452 lines
12 KiB
C#
452 lines
12 KiB
C#
using System;
|
||
using System.IO;
|
||
using System.Windows;
|
||
using System.Windows.Controls;
|
||
using System.Windows.Input;
|
||
using System.Windows.Media;
|
||
using System.Windows.Media.Imaging;
|
||
|
||
namespace WxOrder.Framework.Controls;
|
||
|
||
/// <summary>
|
||
/// 图片查看弹窗,直接静态调用:
|
||
/// ImageViewerWindow.Open(@"C:\a.jpg");
|
||
/// ImageViewerWindow.OpenDialog("https://example.com/a.png", "预览");
|
||
/// source 支持 string(路径/URL) / Uri / ImageSource / byte[] / Stream
|
||
/// </summary>
|
||
public partial class ImageViewerWindow : Window
|
||
{
|
||
private readonly object _source;
|
||
|
||
private ImageViewerWindow(object source, string title)
|
||
{
|
||
InitializeComponent();
|
||
|
||
// 保证 Img 位于 Canvas 中:
|
||
// 如果 Img 直接放在 Grid 里,图片比窗口大时会被 WPF 按窗口大小做布局裁剪,
|
||
// 缩放后只剩左上角一块,表现为“图片没居中”。
|
||
EnsureImageInCanvas();
|
||
|
||
var group = new TransformGroup();
|
||
group.Children.Add(_scaleT);
|
||
group.Children.Add(_translateT);
|
||
Img.RenderTransform = group;
|
||
Img.RenderTransformOrigin = new Point(0, 0);
|
||
|
||
_source = source;
|
||
Title = title ?? GuessTitle(source);
|
||
|
||
// 默认大小:屏幕工作区的 70%
|
||
Width = Math.Max(MinWidth, SystemParameters.WorkArea.Width * 0.7);
|
||
Height = Math.Max(MinHeight, SystemParameters.WorkArea.Height * 0.7);
|
||
|
||
ImageFailed += (s, ex) =>
|
||
{
|
||
MessageBox.Show(this, "图片加载失败:" + ex.Message, "提示",
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
Close();
|
||
};
|
||
|
||
// 放到 Loaded 里设置,这样加载失败时可以安全地关闭窗口
|
||
Loaded += (s, e) => Source = _source;
|
||
}
|
||
|
||
/// <summary>如果 Img 的父级不是 Canvas,就在原位置包一层 Canvas。</summary>
|
||
private void EnsureImageInCanvas()
|
||
{
|
||
if (Img.Parent is Canvas) return;
|
||
if (Img.Parent is not Panel parent) return;
|
||
|
||
int index = parent.Children.IndexOf(Img);
|
||
parent.Children.RemoveAt(index);
|
||
|
||
Img.Stretch = Stretch.None;
|
||
Img.HorizontalAlignment = HorizontalAlignment.Left;
|
||
Img.VerticalAlignment = VerticalAlignment.Top;
|
||
Canvas.SetLeft(Img, 0);
|
||
Canvas.SetTop(Img, 0);
|
||
|
||
var canvas = new Canvas { IsHitTestVisible = false };
|
||
canvas.Children.Add(Img);
|
||
parent.Children.Insert(index, canvas);
|
||
}
|
||
|
||
#region 静态调用入口
|
||
|
||
/// <summary>非模态弹出(不阻塞调用方)</summary>
|
||
public static void Open(object source, string title = null, Window owner = null)
|
||
{
|
||
RunOnUi(() => Create(source, title, owner).Show());
|
||
}
|
||
|
||
/// <summary>模态弹出(关闭前阻塞调用方)</summary>
|
||
public static void OpenDialog(object source, string title = null, Window owner = null)
|
||
{
|
||
RunOnUi(() => Create(source, title, owner).ShowDialog());
|
||
}
|
||
|
||
#endregion
|
||
|
||
private static ImageViewerWindow Create(object source, string title, Window owner)
|
||
{
|
||
var win = new ImageViewerWindow(source, title);
|
||
|
||
owner = owner ?? Application.Current?.MainWindow;
|
||
if (owner != null && owner.IsLoaded && owner != win)
|
||
{
|
||
win.Owner = owner;
|
||
win.WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||
}
|
||
else
|
||
{
|
||
win.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||
}
|
||
return win;
|
||
}
|
||
|
||
private static void RunOnUi(Action action)
|
||
{
|
||
var dispatcher = Application.Current?.Dispatcher
|
||
?? throw new InvalidOperationException("没有可用的 WPF Application。");
|
||
if (dispatcher.CheckAccess()) action();
|
||
else dispatcher.Invoke(action); // 支持从后台线程调用
|
||
}
|
||
|
||
private static string GuessTitle(object source)
|
||
{
|
||
try
|
||
{
|
||
if (source is string s && !string.IsNullOrWhiteSpace(s))
|
||
{
|
||
if (Uri.TryCreate(s, UriKind.Absolute, out var u))
|
||
return Path.GetFileName(u.IsFile ? u.LocalPath : u.AbsolutePath);
|
||
return Path.GetFileName(s);
|
||
}
|
||
if (source is Uri uri)
|
||
return Path.GetFileName(uri.IsFile ? uri.LocalPath : uri.AbsolutePath);
|
||
}
|
||
catch { /* 忽略,使用默认标题 */ }
|
||
return "图片查看";
|
||
}
|
||
|
||
private void Window_KeyDown(object sender, KeyEventArgs e)
|
||
{
|
||
switch (e.Key)
|
||
{
|
||
case Key.Escape: Close(); break;
|
||
case Key.Add:
|
||
case Key.OemPlus: ZoomIn(); break;
|
||
case Key.Subtract:
|
||
case Key.OemMinus: ZoomOut(); break;
|
||
case Key.D0:
|
||
case Key.NumPad0: FitToView(); break;
|
||
case Key.D1:
|
||
case Key.NumPad1: ActualSize(); break;
|
||
}
|
||
}
|
||
|
||
#region 依赖属性
|
||
|
||
/// <summary>图片来源:string(路径或URL) / Uri / ImageSource / byte[] / Stream</summary>
|
||
public static readonly DependencyProperty SourceProperty =
|
||
DependencyProperty.Register(nameof(Source), typeof(object), typeof(ImageViewerWindow),
|
||
new PropertyMetadata(null, (d, e) => ((ImageViewerWindow)d).LoadSource(e.NewValue)));
|
||
|
||
public static readonly DependencyProperty ShowToolbarProperty =
|
||
DependencyProperty.Register(nameof(ShowToolbar), typeof(bool), typeof(ImageViewerWindow),
|
||
new PropertyMetadata(true, (d, e) =>
|
||
((ImageViewerWindow)d).ToolBar.Visibility = (bool)e.NewValue ? Visibility.Visible : Visibility.Collapsed));
|
||
|
||
public object Source
|
||
{
|
||
get => GetValue(SourceProperty);
|
||
set => SetValue(SourceProperty, value);
|
||
}
|
||
|
||
public bool ShowToolbar
|
||
{
|
||
get => (bool)GetValue(ShowToolbarProperty);
|
||
set => SetValue(ShowToolbarProperty, value);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 公共属性 / 事件
|
||
|
||
public double MinZoom { get; set; } = 0.05;
|
||
public double MaxZoom { get; set; } = 32;
|
||
|
||
/// <summary>当前缩放比例(1 = 100%)</summary>
|
||
public double Zoom
|
||
{
|
||
get => _scale;
|
||
set => ZoomAt(ViewCenter, value);
|
||
}
|
||
|
||
public event EventHandler ZoomChanged;
|
||
public event EventHandler<Exception> ImageFailed;
|
||
|
||
#endregion
|
||
|
||
private readonly ScaleTransform _scaleT = new ScaleTransform();
|
||
private readonly TranslateTransform _translateT = new TranslateTransform();
|
||
|
||
private ImageSource _image;
|
||
private BitmapSource _pending; // 正在下载的网络图片
|
||
private double _scale = 1, _tx, _ty;
|
||
private bool _fit = true; // 是否处于"适应窗口"状态(窗口大小变化时自动重新适应)
|
||
private bool _dragging;
|
||
private Point _lastPos;
|
||
|
||
private Point ViewCenter => new Point(Root.ActualWidth / 2, Root.ActualHeight / 2);
|
||
|
||
#region 加载图片
|
||
|
||
private void LoadSource(object src)
|
||
{
|
||
DetachPending();
|
||
try
|
||
{
|
||
var img = CreateImage(src);
|
||
if (img is BitmapSource bs && bs.IsDownloading)
|
||
{
|
||
_pending = bs;
|
||
bs.DownloadCompleted += OnDownloadCompleted;
|
||
bs.DownloadFailed += OnDownloadFailed;
|
||
SetImage(null);
|
||
return;
|
||
}
|
||
SetImage(img);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
SetImage(null);
|
||
ImageFailed?.Invoke(this, ex);
|
||
}
|
||
}
|
||
|
||
private void DetachPending()
|
||
{
|
||
if (_pending == null) return;
|
||
_pending.DownloadCompleted -= OnDownloadCompleted;
|
||
_pending.DownloadFailed -= OnDownloadFailed;
|
||
_pending = null;
|
||
}
|
||
|
||
private void OnDownloadCompleted(object sender, EventArgs e)
|
||
{
|
||
var bs = (BitmapSource)sender;
|
||
if (bs != _pending) return;
|
||
DetachPending();
|
||
if (bs.CanFreeze) bs.Freeze();
|
||
SetImage(bs);
|
||
}
|
||
|
||
private void OnDownloadFailed(object sender, ExceptionEventArgs e)
|
||
{
|
||
DetachPending();
|
||
SetImage(null);
|
||
ImageFailed?.Invoke(this, e.ErrorException);
|
||
}
|
||
|
||
private static ImageSource CreateImage(object src)
|
||
{
|
||
switch (src)
|
||
{
|
||
case null: return null;
|
||
case ImageSource s: return s;
|
||
case Uri u: return FromUri(u);
|
||
case string str:
|
||
var uri = ToUri(str);
|
||
return uri == null ? null : FromUri(uri);
|
||
case byte[] bytes:
|
||
using (var ms = new MemoryStream(bytes)) return FromStream(ms);
|
||
case Stream stream: return FromStream(stream);
|
||
default:
|
||
throw new NotSupportedException("不支持的图片来源类型:" + src.GetType().FullName);
|
||
}
|
||
}
|
||
|
||
private static Uri ToUri(string s)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(s)) return null;
|
||
if (Uri.TryCreate(s, UriKind.Absolute, out var u)) return u; // C:\a.png / http://... / pack://...
|
||
return new Uri(Path.GetFullPath(s)); // 相对路径
|
||
}
|
||
|
||
private static BitmapImage FromUri(Uri uri)
|
||
{
|
||
var bmp = new BitmapImage();
|
||
bmp.BeginInit();
|
||
bmp.UriSource = uri;
|
||
bmp.CacheOption = BitmapCacheOption.OnLoad; // 加载后不占用文件
|
||
bmp.CreateOptions = BitmapCreateOptions.IgnoreImageCache; // 文件被替换后能刷新
|
||
bmp.EndInit();
|
||
if (!bmp.IsDownloading && bmp.CanFreeze) bmp.Freeze();
|
||
return bmp;
|
||
}
|
||
|
||
private static BitmapImage FromStream(Stream stream)
|
||
{
|
||
var bmp = new BitmapImage();
|
||
bmp.BeginInit();
|
||
bmp.StreamSource = stream;
|
||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||
bmp.EndInit();
|
||
bmp.Freeze();
|
||
return bmp;
|
||
}
|
||
|
||
private void SetImage(ImageSource img)
|
||
{
|
||
_image = img;
|
||
Img.Source = img;
|
||
_fit = true;
|
||
FitToView();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 缩放 / 平移
|
||
|
||
/// <summary>适应窗口(只缩小,不放大小图)</summary>
|
||
public void FitToView()
|
||
{
|
||
_fit = true;
|
||
if (_image == null) return;
|
||
double cw = Root.ActualWidth, ch = Root.ActualHeight;
|
||
if (cw <= 0 || ch <= 0 || _image.Width <= 0 || _image.Height <= 0) return;
|
||
|
||
CenterAtScale(Math.Min(1.0, Math.Min(cw / _image.Width, ch / _image.Height)));
|
||
}
|
||
|
||
/// <summary>按原始大小显示,并将图片几何中心放到视口中心。</summary>
|
||
public void ActualSize()
|
||
{
|
||
CenterAtScale(1.0);
|
||
_fit = false;
|
||
}
|
||
|
||
public void ZoomIn() => ZoomAt(ViewCenter, _scale * 1.25);
|
||
public void ZoomOut() => ZoomAt(ViewCenter, _scale / 1.25);
|
||
|
||
/// <summary>以 p(控件坐标)为不动点缩放到 newScale</summary>
|
||
private void ZoomAt(Point p, double newScale)
|
||
{
|
||
if (_image == null) return;
|
||
newScale = Math.Max(MinZoom, Math.Min(MaxZoom, newScale));
|
||
double ratio = newScale / _scale;
|
||
_tx = p.X - (p.X - _tx) * ratio;
|
||
_ty = p.Y - (p.Y - _ty) * ratio;
|
||
_scale = newScale;
|
||
_fit = false;
|
||
ApplyTransform();
|
||
}
|
||
|
||
/// <summary>按指定比例显示,并将图片几何中心与视口中心对齐。</summary>
|
||
private void CenterAtScale(double newScale)
|
||
{
|
||
if (_image == null) return;
|
||
|
||
_scale = Math.Max(MinZoom, Math.Min(MaxZoom, newScale));
|
||
_tx = (Root.ActualWidth - _image.Width * _scale) / 2;
|
||
_ty = (Root.ActualHeight - _image.Height * _scale) / 2;
|
||
ApplyTransform();
|
||
}
|
||
|
||
private void ApplyTransform()
|
||
{
|
||
if (_image == null)
|
||
{
|
||
ZoomText.Text = "--";
|
||
return;
|
||
}
|
||
|
||
ClampOffset();
|
||
_scaleT.ScaleX = _scaleT.ScaleY = _scale;
|
||
_translateT.X = _tx;
|
||
_translateT.Y = _ty;
|
||
|
||
// 缩小用高质量,放大到像素级时用最近邻(能看清像素)
|
||
RenderOptions.SetBitmapScalingMode(Img,
|
||
_scale >= 3 ? BitmapScalingMode.NearestNeighbor :
|
||
_scale < 1 ? BitmapScalingMode.Fant : BitmapScalingMode.Linear);
|
||
|
||
ZoomText.Text = $"{_scale * 100:0}%";
|
||
ZoomChanged?.Invoke(this, EventArgs.Empty);
|
||
}
|
||
|
||
/// <summary>图片小于视口时居中;大于视口时不允许拖出空白</summary>
|
||
private void ClampOffset()
|
||
{
|
||
double cw = Root.ActualWidth, ch = Root.ActualHeight;
|
||
double w = _image.Width * _scale, h = _image.Height * _scale;
|
||
_tx = w <= cw ? (cw - w) / 2 : Math.Min(0, Math.Max(cw - w, _tx));
|
||
_ty = h <= ch ? (ch - h) / 2 : Math.Min(0, Math.Max(ch - h, _ty));
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 事件处理
|
||
|
||
private void Root_SizeChanged(object sender, SizeChangedEventArgs e)
|
||
{
|
||
if (_image == null) return;
|
||
if (_fit) FitToView();
|
||
else ApplyTransform();
|
||
}
|
||
|
||
private void Root_MouseWheel(object sender, MouseWheelEventArgs e)
|
||
{
|
||
if (_image == null) return;
|
||
double factor = Math.Pow(1.001, e.Delta); // 一格滚轮约 ×1.13,触摸板更平滑
|
||
ZoomAt(e.GetPosition(Root), _scale * factor);
|
||
e.Handled = true;
|
||
}
|
||
|
||
private void Root_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||
{
|
||
if (_image == null) return;
|
||
|
||
if (e.ClickCount == 2) // 双击:适应窗口 <-> 100%
|
||
{
|
||
if (_fit) ZoomAt(e.GetPosition(Root), 1.0);
|
||
else FitToView();
|
||
return;
|
||
}
|
||
|
||
_dragging = true;
|
||
_lastPos = e.GetPosition(Root);
|
||
Root.CaptureMouse();
|
||
Root.Cursor = Cursors.SizeAll;
|
||
}
|
||
|
||
private void Root_MouseMove(object sender, MouseEventArgs e)
|
||
{
|
||
if (!_dragging) return;
|
||
var pos = e.GetPosition(Root);
|
||
_tx += pos.X - _lastPos.X;
|
||
_ty += pos.Y - _lastPos.Y;
|
||
_lastPos = pos;
|
||
_fit = false;
|
||
ApplyTransform();
|
||
}
|
||
|
||
private void Root_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => EndDrag();
|
||
private void Root_LostMouseCapture(object sender, MouseEventArgs e) => EndDrag();
|
||
|
||
private void EndDrag()
|
||
{
|
||
if (!_dragging) return;
|
||
_dragging = false;
|
||
Root.ReleaseMouseCapture();
|
||
Root.Cursor = null;
|
||
}
|
||
|
||
private void ZoomIn_Click(object sender, RoutedEventArgs e) => ZoomIn();
|
||
private void ZoomOut_Click(object sender, RoutedEventArgs e) => ZoomOut();
|
||
private void Fit_Click(object sender, RoutedEventArgs e) => FitToView();
|
||
private void Actual_Click(object sender, RoutedEventArgs e) => ActualSize();
|
||
|
||
#endregion
|
||
} |