diff --git a/Unicord.Universal/Controls/ScaledContentControl.cs b/Unicord.Universal/Controls/ScaledContentControl.cs
index b9deffeb..da0572d1 100644
--- a/Unicord.Universal/Controls/ScaledContentControl.cs
+++ b/Unicord.Universal/Controls/ScaledContentControl.cs
@@ -35,6 +35,15 @@ public bool ForceSize
public static readonly DependencyProperty ForceSizeProperty =
DependencyProperty.Register("ForceSize", typeof(bool), typeof(ScaledContentControl), new PropertyMetadata(false, OnWidthHeightPropertyChanged));
+ public bool UseFullscreen
+ {
+ get { return (bool)GetValue(UseFullscreenProperty); }
+ set { SetValue(UseFullscreenProperty, value); }
+ }
+
+ public static readonly DependencyProperty UseFullscreenProperty =
+ DependencyProperty.Register("UseFullscreen", typeof(bool), typeof(ScaledContentControl), new PropertyMetadata(false, OnWidthHeightPropertyChanged));
+
private static void OnWidthHeightPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (ScaledContentControl)d;
@@ -58,14 +67,39 @@ protected override Size MeasureOverride(Size constraint)
double width = TargetWidth;
double height = TargetHeight;
- if (double.IsNaN(width) || double.IsNaN(height))
+ if (double.IsNaN(width) || double.IsNaN(height) || width <= 0 || height <= 0)
return base.MeasureOverride(constraint);
- var maxWidth = Math.Min(root.Bounds.Width, Math.Min(MaxWidth, constraint.Width));
- var maxHeight = Math.Min(root.Bounds.Height - 32, Math.Min(MaxHeight, constraint.Height));
+ var horizontalMargin = UseFullscreen ? 0 : 80;
+ var verticalMargin = UseFullscreen ? 0 : 160;
+
+ var maxWidth = Math.Min(root.Bounds.Width - horizontalMargin, Math.Min(MaxWidth, constraint.Width));
+ var maxHeight = Math.Min(root.Bounds.Height - verticalMargin, Math.Min(MaxHeight, constraint.Height));
- Drawing.ScaleProportions(ref width, ref height, maxWidth, maxHeight);
- Drawing.ScaleProportions(ref width, ref height, Math.Min(constraint.Width, maxWidth), Math.Min(constraint.Height, maxHeight));
+ if (UseFullscreen)
+ {
+ // Scale to cover the available area (fill both width and height) so panning can traverse full image
+ var scaleX = maxWidth / width;
+ var scaleY = maxHeight / height;
+ var scale = Math.Max(scaleX, scaleY);
+
+ // Apply scale
+ width = width * scale;
+ height = height * scale;
+ }
+ else
+ {
+ // Normal mode: Fit within maxWidth/maxHeight (contain)
+ // only scale down if the image is larger than the available space
+ if (width > maxWidth || height > maxHeight)
+ {
+ var scaleX = maxWidth / width;
+ var scaleY = maxHeight / height;
+ var scale = Math.Min(scaleX, scaleY);
+ width *= scale;
+ height *= scale;
+ }
+ }
if (ForceSize && Content is FrameworkElement element)
{
@@ -73,6 +107,11 @@ protected override Size MeasureOverride(Size constraint)
element.Height = height;
}
+ if (Content is UIElement child)
+ {
+ child.Measure(new Size(width, height));
+ }
+
return new Size(width, height);
}
diff --git a/Unicord.Universal/Models/ViewModelbase.cs b/Unicord.Universal/Models/ViewModelbase.cs
index 9da769ab..b40afe07 100644
--- a/Unicord.Universal/Models/ViewModelbase.cs
+++ b/Unicord.Universal/Models/ViewModelbase.cs
@@ -14,12 +14,15 @@ public abstract class ViewModelBase : INotifyPropertyChanged
public ViewModelBase(ViewModelBase parent = null)
{
+ Parent = parent;
discord = DiscordManager.Discord; // capture the discord client
syncContext = parent?.syncContext ?? SynchronizationContext.Current;
Debug.Assert(discord != null);
Debug.Assert(syncContext != null);
}
+ public ViewModelBase Parent { get; }
+
public event PropertyChangedEventHandler PropertyChanged;
// Holy hell is the C# Discord great.
diff --git a/Unicord.Universal/Pages/Overlay/AttachmentOverlayPage.xaml b/Unicord.Universal/Pages/Overlay/AttachmentOverlayPage.xaml
index e7fecb7b..a3039ad4 100644
--- a/Unicord.Universal/Pages/Overlay/AttachmentOverlayPage.xaml
+++ b/Unicord.Universal/Pages/Overlay/AttachmentOverlayPage.xaml
@@ -1,52 +1,91 @@
+x:Class="Unicord.Universal.Pages.Overlay.AttachmentOverlayPage"
+xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+xmlns:local="using:Unicord.Universal.Pages.Overlay"
+xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+xmlns:lib="using:Microsoft.UI.Xaml.Controls"
+xmlns:controls="using:Unicord.Universal.Controls"
+xmlns:media="using:Microsoft.Toolkit.Uwp.UI.Media"
+xmlns:toolkit="using:Microsoft.Toolkit.Uwp.UI.Controls"
+xmlns:ui="using:Microsoft.Toolkit.Uwp.UI"
+xmlns:w1709="http://schemas.microsoft.com/winfx/2006/xaml/presentation?IsApiContractPresent(Windows.Foundation.UniversalApiContract, 5)"
+RequestedTheme="Dark"
+mc:Ignorable="d">
+ Tapped="contentContainer_Tapped"
+ Background="#B8000000"/>
-
+ ZoomMode="Enabled"
+ Tapped="contentContainer_Tapped"
+ PointerPressed="imageScrollViewer_PointerPressed"
+ PointerMoved="imageScrollViewer_PointerMoved"
+ PointerReleased="imageScrollViewer_PointerReleased"
+ PointerCanceled="imageScrollViewer_PointerReleased">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Unicord.Universal/Pages/Overlay/AttachmentOverlayPage.xaml.cs b/Unicord.Universal/Pages/Overlay/AttachmentOverlayPage.xaml.cs
index 2c667329..8d27a204 100644
--- a/Unicord.Universal/Pages/Overlay/AttachmentOverlayPage.xaml.cs
+++ b/Unicord.Universal/Pages/Overlay/AttachmentOverlayPage.xaml.cs
@@ -1,20 +1,41 @@
using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net.Http;
using Unicord.Universal.Models.Messages;
using Unicord.Universal.Services;
+using Unicord.Universal.Converters;
using Windows.Foundation;
+using Windows.UI;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Input;
+using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Media.Imaging;
using Windows.UI.Xaml.Navigation;
+using Windows.System;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.ApplicationModel.Resources;
+using Windows.Storage;
+using Windows.Storage.Streams;
namespace Unicord.Universal.Pages.Overlay
{
public sealed partial class AttachmentOverlayPage : Page, IOverlay
{
+ private bool _isZoomMode;
+ private bool _isPanning;
+ private Point _lastPointerPosition;
+ private MessageViewModel _messageViewModel;
+ private ulong _attachmentId;
+ private Uri _currentAttachmentUri;
+ private string _originalFileName;
+
public AttachmentOverlayPage()
{
this.InitializeComponent();
+ UpdateZoomButtonVisual();
+ DataTransferManager.GetForCurrentView().DataRequested += OnDataRequested;
}
public Size PreferredSize { get; }
@@ -29,21 +50,42 @@ protected override void OnNavigatedTo(NavigationEventArgs e)
{
scaledControl.TargetWidth = attachment.NaturalWidth;
scaledControl.TargetHeight = attachment.NaturalHeight;
- attachmentImage.MaxWidth = attachment.NaturalWidth;
- attachmentImage.MaxHeight = attachment.NaturalHeight;
AttachmentSource.UriSource = new Uri(attachment.ProxyUrl);
+ _currentAttachmentUri = new Uri(attachment.ProxyUrl);
+
+ _messageViewModel = attachment.Parent as MessageViewModel;
+ // Access the attachment ID through reflection since it's not exposed as a public property
+ var attachmentType = attachment.GetType();
+ var attachmentField = attachmentType.GetField("_attachment", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ if (attachmentField?.GetValue(attachment) is DSharpPlus.Entities.DiscordAttachment discordAttachment)
+ {
+ _attachmentId = discordAttachment.Id;
+ _originalFileName = discordAttachment.FileName;
+ }
+ UpdateSenderInfo();
}
if (e.Parameter is EmbedImageViewModel thumbnail)
{
scaledControl.TargetWidth = thumbnail.NaturalWidth;
scaledControl.TargetHeight = thumbnail.NaturalHeight;
- attachmentImage.MaxWidth = thumbnail.NaturalWidth;
- attachmentImage.MaxHeight = thumbnail.NaturalHeight;
AttachmentSource.UriSource = new Uri(thumbnail.Url);
+ _currentAttachmentUri = new Uri(thumbnail.Url);
+
+ var embedViewModel = thumbnail.Parent as EmbedViewModel;
+ _messageViewModel = embedViewModel?.Parent as MessageViewModel;
+ _attachmentId = 0; // Embed images don't have attachment IDs
+ UpdateSenderInfo();
}
+
+ // Always monitor view changes so pinch/scroll can dynamically toggle zoom mode
+ imageScrollViewer.ViewChanged -= ImageScrollViewer_ViewChanged;
+ imageScrollViewer.ViewChanged += ImageScrollViewer_ViewChanged;
+
+ // Initialize zoom combobox
+ UpdateZoomComboBox();
}
private void AttachmentSource_DownloadProgress(object sender, DownloadProgressEventArgs e)
@@ -76,5 +118,587 @@ private void attachmentImage_Tapped(object sender, TappedRoutedEventArgs e)
{
e.Handled = true;
}
+
+ private void attachmentImage_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+ {
+ e.Handled = true;
+
+ // If currently not in zoom mode, zoom into the tapped point.
+ if (!_isZoomMode)
+ {
+ // Compute the same target zoom used by the toggle (cover * 1.5)
+ var w = scaledControl.TargetWidth;
+ var h = scaledControl.TargetHeight;
+ var bounds = Window.Current.Bounds;
+ var winW = bounds.Width;
+ var winH = bounds.Height;
+
+ var mw_n = winW - 80;
+ var mh_n = winH - 160;
+ var s_c = Math.Min(1.0, Math.Min(mw_n / w, mh_n / h));
+ var s_v = Math.Max(winW / w, winH / h);
+ var targetZoom = (float)((s_v / s_c) * 1.5);
+
+ // Get pointer position relative to the scaled content
+ var pointer = e.GetPosition(scaledControl);
+
+ // Calculate target offsets so the pointer stays centered after zoom
+ var targetWidth = scaledControl.ActualWidth * targetZoom;
+ var targetHeight = scaledControl.ActualHeight * targetZoom;
+ var centerX = Math.Max(0, pointer.X * targetZoom - imageScrollViewer.ViewportWidth / 2);
+ var centerY = Math.Max(0, pointer.Y * targetZoom - imageScrollViewer.ViewportHeight / 2);
+
+ // Clamp to scrollable area
+ centerX = Math.Min(centerX, Math.Max(0, targetWidth - imageScrollViewer.ViewportWidth));
+ centerY = Math.Min(centerY, Math.Max(0, targetHeight - imageScrollViewer.ViewportHeight));
+
+ // Enter zoom mode state
+ _isZoomMode = true;
+ UpdateZoomButtonVisual();
+ background.Background = new SolidColorBrush(Color.FromArgb(0xB8, 0x00, 0x00, 0x00));
+
+ // Ensure we monitor view changes and enable zoom
+ EnsureZoomEnabled();
+
+ imageScrollViewer.ChangeView(centerX, centerY, targetZoom, false);
+ }
+ else
+ {
+ // Exit zoom mode
+ _isZoomMode = false;
+ UpdateZoomButtonVisual();
+
+ // Animate back to fit
+ imageScrollViewer.ChangeView(0, 0, 1.0f, false);
+ }
+ }
+
+ private void ZoomIn_Click(object sender, RoutedEventArgs e)
+ {
+ var current = imageScrollViewer.ZoomFactor;
+ var step = 1.25f;
+ var target = Math.Min(imageScrollViewer.MaxZoomFactor, current * step);
+
+ // Calculate viewport center in content coordinates
+ var viewportW = imageScrollViewer.ViewportWidth;
+ var viewportH = imageScrollViewer.ViewportHeight;
+ var contentCenterX = imageScrollViewer.HorizontalOffset + viewportW / 2.0;
+ var contentCenterY = imageScrollViewer.VerticalOffset + viewportH / 2.0;
+
+ var ratio = target / current;
+
+ // Map center to new offset so center remains centered
+ var newCenterX = contentCenterX * ratio;
+ var newCenterY = contentCenterY * ratio;
+ var targetOffsetX = Math.Max(0, newCenterX - viewportW / 2.0);
+ var targetOffsetY = Math.Max(0, newCenterY - viewportH / 2.0);
+
+ // Clamp to max scrollable size
+ var contentWidth = scaledControl.ActualWidth * target;
+ var contentHeight = scaledControl.ActualHeight * target;
+ var maxOffsetX = Math.Max(0, contentWidth - viewportW);
+ var maxOffsetY = Math.Max(0, contentHeight - viewportH);
+ targetOffsetX = Math.Min(targetOffsetX, maxOffsetX);
+ targetOffsetY = Math.Min(targetOffsetY, maxOffsetY);
+
+ // If not in zoom mode, enter it
+ if (!_isZoomMode)
+ {
+ _isZoomMode = true;
+ UpdateZoomButtonVisual();
+ background.Background = new SolidColorBrush(Color.FromArgb(0xB8, 0x00, 0x00, 0x00));
+ EnsureZoomEnabled();
+ }
+
+ imageScrollViewer.ChangeView(targetOffsetX, targetOffsetY, target, false);
+ }
+
+ private void ZoomOut_Click(object sender, RoutedEventArgs e)
+ {
+ var current = imageScrollViewer.ZoomFactor;
+ var step = 1.25f;
+ var target = Math.Max(1.0f, current / step);
+
+ // Calculate viewport center in content coordinates
+ var viewportW = imageScrollViewer.ViewportWidth;
+ var viewportH = imageScrollViewer.ViewportHeight;
+ var contentCenterX = imageScrollViewer.HorizontalOffset + viewportW / 2.0;
+ var contentCenterY = imageScrollViewer.VerticalOffset + viewportH / 2.0;
+
+ var ratio = target / current;
+ var newCenterX = contentCenterX * ratio;
+ var newCenterY = contentCenterY * ratio;
+ var targetOffsetX = Math.Max(0, newCenterX - viewportW / 2.0);
+ var targetOffsetY = Math.Max(0, newCenterY - viewportH / 2.0);
+
+ var contentWidth = scaledControl.ActualWidth * target;
+ var contentHeight = scaledControl.ActualHeight * target;
+ var maxOffsetX = Math.Max(0, contentWidth - viewportW);
+ var maxOffsetY = Math.Max(0, contentHeight - viewportH);
+ targetOffsetX = Math.Min(targetOffsetX, maxOffsetX);
+ targetOffsetY = Math.Min(targetOffsetY, maxOffsetY);
+
+ imageScrollViewer.ChangeView(targetOffsetX, targetOffsetY, target, false);
+ }
+
+ private void imageScrollViewer_PointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ var properties = e.GetCurrentPoint(imageScrollViewer).Properties;
+ // allow panning when user has zoomed in or when zoom-mode was explicitly enabled
+ if (properties.IsLeftButtonPressed && (imageScrollViewer.ZoomFactor > 1.0 || _isZoomMode))
+ {
+ _isPanning = true;
+ _lastPointerPosition = e.GetCurrentPoint(imageScrollViewer).Position;
+ imageScrollViewer.CapturePointer(e.Pointer);
+ }
+ }
+
+ private void imageScrollViewer_PointerMoved(object sender, PointerRoutedEventArgs e)
+ {
+ var properties = e.GetCurrentPoint(imageScrollViewer).Properties;
+
+ // Stop panning if left button is no longer pressed
+ if (_isPanning && !properties.IsLeftButtonPressed)
+ {
+ _isPanning = false;
+ return;
+ }
+
+ if (_isPanning)
+ {
+ var currentPosition = e.GetCurrentPoint(imageScrollViewer).Position;
+ var deltaX = currentPosition.X - _lastPointerPosition.X;
+ var deltaY = currentPosition.Y - _lastPointerPosition.Y;
+
+ imageScrollViewer.ChangeView(imageScrollViewer.HorizontalOffset - deltaX, imageScrollViewer.VerticalOffset - deltaY, null, true);
+ _lastPointerPosition = currentPosition;
+ }
+ }
+
+ private void imageScrollViewer_PointerReleased(object sender, PointerRoutedEventArgs e)
+ {
+ if (_isPanning)
+ {
+ _isPanning = false;
+ imageScrollViewer.ReleasePointerCapture(e.Pointer);
+ }
+ }
+
+ private async void OpenInBrowser_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var uri = AttachmentSource?.UriSource;
+ if (uri != null)
+ {
+ await Launcher.LaunchUriAsync(uri);
+ }
+ }
+ catch { /* ignore failures silently */ }
+ }
+
+ private async void SaveAs_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var uri = AttachmentSource?.UriSource;
+ if (uri != null)
+ {
+ var savePicker = new Windows.Storage.Pickers.FileSavePicker();
+ savePicker.SuggestedStartLocation = Windows.Storage.Pickers.PickerLocationId.PicturesLibrary;
+
+ // Detect file extension from original filename
+ string extension = ".png";
+ string fileNameWithoutExt = "attachment";
+
+ if (!string.IsNullOrEmpty(_originalFileName))
+ {
+ extension = System.IO.Path.GetExtension(_originalFileName).ToLowerInvariant();
+ fileNameWithoutExt = System.IO.Path.GetFileNameWithoutExtension(_originalFileName);
+
+ if (string.IsNullOrEmpty(extension))
+ extension = ".png";
+ }
+
+ // Map extension to file type choice
+ var fileTypeName = extension.TrimStart('.').ToUpperInvariant() + " Image";
+ savePicker.FileTypeChoices.Add(fileTypeName, new List { extension });
+
+ // Add other common image formats as alternatives
+ if (extension != ".png")
+ savePicker.FileTypeChoices.Add("PNG Image", new List { ".png" });
+ if (extension != ".jpg" && extension != ".jpeg")
+ savePicker.FileTypeChoices.Add("JPEG Image", new List { ".jpg", ".jpeg" });
+ if (extension != ".gif")
+ savePicker.FileTypeChoices.Add("GIF Image", new List { ".gif" });
+ if (extension != ".webp")
+ savePicker.FileTypeChoices.Add("WebP Image", new List { ".webp" });
+
+ savePicker.SuggestedFileName = fileNameWithoutExt;
+
+ var file = await savePicker.PickSaveFileAsync();
+ if (file != null)
+ {
+ try
+ {
+ using (var httpClient = new System.Net.Http.HttpClient())
+ {
+ var bytes = await httpClient.GetByteArrayAsync(uri);
+ await Windows.Storage.FileIO.WriteBytesAsync(file, bytes);
+ }
+
+ // Show success banner
+ ShowSuccessBanner();
+ }
+ catch { /* ignore download errors */ }
+ }
+ }
+ }
+ catch { /* ignore failures silently */ }
+ }
+
+ private void Share_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ DataTransferManager.ShowShareUI();
+ }
+ catch { /* ignore failures silently */ }
+ }
+
+ private async void OnDataRequested(DataTransferManager sender, DataRequestedEventArgs args)
+ {
+ if (_currentAttachmentUri != null)
+ {
+ var deferral = args.Request.GetDeferral();
+ try
+ {
+ var request = args.Request;
+
+ // Download the image to a temporary file
+ var tempFolder = ApplicationData.Current.TemporaryFolder;
+ var fileName = Path.GetFileName(_currentAttachmentUri.LocalPath);
+ if (string.IsNullOrEmpty(fileName) || fileName == "/")
+ {
+ fileName = "image.png";
+ }
+
+ var tempFile = await tempFolder.CreateFileAsync(fileName, CreationCollisionOption.ReplaceExisting);
+
+ using (var client = new HttpClient())
+ {
+ var imageData = await client.GetByteArrayAsync(_currentAttachmentUri);
+ await FileIO.WriteBytesAsync(tempFile, imageData);
+ }
+
+ // Share the file with thumbnail
+ var storageItems = new List { tempFile };
+ request.Data.SetStorageItems(storageItems);
+
+ // Set thumbnail for preview
+ var thumbnail = RandomAccessStreamReference.CreateFromFile(tempFile);
+ request.Data.Properties.Thumbnail = thumbnail;
+
+ request.Data.Properties.Title = fileName;
+ request.Data.Properties.Description = "Image from Unicord";
+ }
+ catch { /* ignore share data request errors */ }
+ finally
+ {
+ deferral.Complete();
+ }
+ }
+ }
+
+ private void CopyLink_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var uri = AttachmentSource?.UriSource;
+ if (uri != null)
+ {
+ var dp = new DataPackage();
+ dp.SetText(uri.ToString());
+ Clipboard.SetContent(dp);
+ }
+ }
+ catch { /* ignore failures silently */ }
+ }
+
+ private void CopyImages_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var uri = AttachmentSource?.UriSource;
+ if (uri != null)
+ {
+ var dp = new DataPackage();
+ dp.RequestedOperation = DataPackageOperation.Copy;
+ var ras = RandomAccessStreamReference.CreateFromUri(uri);
+ dp.SetBitmap(ras);
+ Clipboard.SetContent(dp);
+ }
+ }
+ catch { /* ignore failures silently */ }
+ }
+
+ private void CopyAttachmentId_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ if (_attachmentId != 0)
+ {
+ var dp = new DataPackage();
+ dp.SetText(_attachmentId.ToString());
+ Clipboard.SetContent(dp);
+ }
+ }
+ catch { /* ignore failures silently */ }
+ }
+
+ private void ImageScrollViewer_ViewChanged(object sender, ScrollViewerViewChangedEventArgs e)
+ {
+ // Update zoom combobox to show current zoom level
+ UpdateZoomComboBox();
+
+ // Ignore intermediate (in-progress) changes; act only when finalized
+ if (e.IsIntermediate)
+ return;
+
+ // If user manually zooms (pinch/ctrl+wheel) beyond 1.0 while toggle is off, enter zoom mode
+ if (!_isZoomMode && imageScrollViewer.ZoomFactor > 1.01)
+ {
+ _isZoomMode = true;
+ UpdateZoomButtonVisual();
+ EnsureZoomEnabled();
+ background.Background = new SolidColorBrush(Color.FromArgb(0xB8, 0x00, 0x00, 0x00));
+ return;
+ }
+
+ // If user manually zooms back to normal while in zoom mode, exit zoom mode (animate to center)
+ if (_isZoomMode && imageScrollViewer.ZoomFactor <= 1.01)
+ {
+ _isZoomMode = false;
+ UpdateZoomButtonVisual();
+
+ // Animate back to centered normal view; after animation completes this handler will be invoked again (final) and clean up
+ imageScrollViewer.ChangeView(0, 0, 1.0f, false);
+ return;
+ }
+
+ // When exiting zoom mode programmatically we animate back to 1.0f; once reached, clean up visuals
+ if (!_isZoomMode && Math.Abs(imageScrollViewer.ZoomFactor - 1.0) < 0.01)
+ {
+ background.Background = new SolidColorBrush(Color.FromArgb(0xB8, 0x00, 0x00, 0x00));
+ }
+ }
+
+ private void ZoomModeButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (!_isZoomMode)
+ EnterZoomMode();
+ else
+ ExitZoomMode();
+ }
+
+ private void EnsureZoomEnabled()
+ {
+ imageScrollViewer.ViewChanged -= ImageScrollViewer_ViewChanged;
+ imageScrollViewer.ViewChanged += ImageScrollViewer_ViewChanged;
+ imageScrollViewer.MaxZoomFactor = 10;
+ imageScrollViewer.ZoomMode = ZoomMode.Enabled;
+ imageScrollViewer.HorizontalScrollMode = ScrollMode.Auto;
+ imageScrollViewer.VerticalScrollMode = ScrollMode.Auto;
+ }
+
+ private void UpdateZoomButtonVisual()
+ {
+ if (zoomFrontIcon != null)
+ zoomFrontIcon.Visibility = _isZoomMode ? Visibility.Collapsed : Visibility.Visible;
+ if (zoomModeButton != null)
+ {
+ var tip = _isZoomMode ? "Zoom to fit" : "Zoom to actual size";
+ ToolTipService.SetToolTip(zoomModeButton, tip);
+ }
+ }
+
+ private void EnterZoomMode()
+ {
+ _isZoomMode = true;
+ UpdateZoomButtonVisual();
+ background.Background = new SolidColorBrush(Color.FromArgb(0xB8, 0x00, 0x00, 0x00));
+ EnsureZoomEnabled();
+
+ var w = scaledControl.TargetWidth;
+ var h = scaledControl.TargetHeight;
+ var bounds = Window.Current.Bounds;
+ var winW = bounds.Width;
+ var winH = bounds.Height;
+
+ var mw_n = winW - 80;
+ var mh_n = winH - 160;
+ var s_c = Math.Min(1.0, Math.Min(mw_n / w, mh_n / h));
+ var s_v = Math.Max(winW / w, winH / h);
+ var targetZoom = (float)((s_v / s_c) * 1.5);
+
+ var targetWidth = scaledControl.ActualWidth * targetZoom;
+ var targetHeight = scaledControl.ActualHeight * targetZoom;
+ var centerX = Math.Max(0, (targetWidth - imageScrollViewer.ViewportWidth) / 2);
+ var centerY = Math.Max(0, (targetHeight - imageScrollViewer.ViewportHeight) / 2);
+
+ imageScrollViewer.ChangeView(centerX, centerY, targetZoom, false);
+ }
+
+ private void ExitZoomMode()
+ {
+ _isPanning = false;
+ _isZoomMode = false;
+ UpdateZoomButtonVisual();
+
+ imageScrollViewer.ViewChanged -= ImageScrollViewer_ViewChanged;
+ imageScrollViewer.ViewChanged += ImageScrollViewer_ViewChanged;
+ imageScrollViewer.ChangeView(0, 0, 1.0f, false);
+ }
+
+ private void UpdateSenderInfo()
+ {
+ if (_messageViewModel != null && senderNameText != null && timestampText != null)
+ {
+ senderNameText.Text = _messageViewModel.Author?.DisplayName ?? "Unknown";
+
+ var timestampStyle = (TimestampStyle)App.RoamingSettings.Read(Constants.TIMESTAMP_STYLE, (int)TimestampStyle.Absolute);
+ timestampText.Text = DateTimeConverter.Convert(timestampStyle, _messageViewModel.Timestamp.DateTime);
+
+ if (avatarBrush != null && _messageViewModel.Author?.AvatarUrl != null)
+ {
+ avatarBrush.ImageSource = new BitmapImage(new Uri(_messageViewModel.Author.AvatarUrl));
+ }
+ }
+ }
+
+ private void UpdateZoomComboBox()
+ {
+ if (zoomComboBox == null) return;
+
+ var currentZoom = imageScrollViewer.ZoomFactor;
+ var percentText = $"{Math.Round(currentZoom * 100)}%";
+
+ // Simply update the text display without triggering events
+ zoomComboBox.SelectionChanged -= ZoomComboBox_SelectionChanged;
+ zoomComboBox.TextSubmitted -= ZoomComboBox_TextSubmitted;
+
+ zoomComboBox.Text = percentText;
+
+ zoomComboBox.SelectionChanged += ZoomComboBox_SelectionChanged;
+ zoomComboBox.TextSubmitted += ZoomComboBox_TextSubmitted;
+ }
+
+ private void ZoomComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (zoomComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tagStr)
+ {
+ if (float.TryParse(tagStr, out var zoomLevel))
+ {
+ SetZoomLevel(zoomLevel);
+ }
+ }
+ }
+
+ private void ZoomComboBox_TextSubmitted(ComboBox sender, ComboBoxTextSubmittedEventArgs args)
+ {
+ ApplyZoomFromText(args.Text);
+ }
+
+ private void ZoomComboBox_LostFocus(object sender, RoutedEventArgs e)
+ {
+ // Update display to show actual current zoom
+ UpdateZoomComboBox();
+ }
+
+ private void ZoomComboBox_KeyDown(object sender, Windows.UI.Xaml.Input.KeyRoutedEventArgs e)
+ {
+ if (e.Key == Windows.System.VirtualKey.Enter)
+ {
+ ApplyZoomFromText(zoomComboBox.Text);
+ e.Handled = true;
+ }
+ }
+
+ private void ApplyZoomFromText(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ UpdateZoomComboBox();
+ return;
+ }
+
+ text = text.Trim().TrimEnd('%');
+ if (float.TryParse(text, out var percent) && percent >= 100 && percent <= 1000)
+ {
+ var zoomLevel = percent / 100f;
+ SetZoomLevel(zoomLevel);
+ }
+ else
+ {
+ UpdateZoomComboBox(); // Reset to current value if invalid
+ }
+ }
+
+ private void SetZoomLevel(float zoomLevel)
+ {
+ // Clamp to valid range
+ zoomLevel = Math.Clamp(zoomLevel, 0.1f, imageScrollViewer.MaxZoomFactor);
+
+ if (zoomLevel <= 1.0f)
+ {
+ // Zoom to fit - exit zoom mode
+ if (_isZoomMode)
+ {
+ ExitZoomMode();
+ }
+ else
+ {
+ imageScrollViewer.ChangeView(0, 0, 1.0f, false);
+ }
+ }
+ else
+ {
+ // Zoom in - enter zoom mode if not already
+ if (!_isZoomMode)
+ {
+ _isZoomMode = true;
+ UpdateZoomButtonVisual();
+ background.Background = new SolidColorBrush(Color.FromArgb(0xB8, 0x00, 0x00, 0x00));
+ EnsureZoomEnabled();
+ }
+
+ // Zoom to center of image content (not viewport center)
+ var viewportW = imageScrollViewer.ViewportWidth;
+ var viewportH = imageScrollViewer.ViewportHeight;
+
+ // Calculate image center position at target zoom
+ var contentWidth = scaledControl.ActualWidth * zoomLevel;
+ var contentHeight = scaledControl.ActualHeight * zoomLevel;
+
+ // Center the image in the viewport
+ var targetOffsetX = Math.Max(0, (contentWidth - viewportW) / 2.0);
+ var targetOffsetY = Math.Max(0, (contentHeight - viewportH) / 2.0);
+
+ imageScrollViewer.ChangeView(targetOffsetX, targetOffsetY, zoomLevel, false);
+ }
+ }
+
+ private async void ShowSuccessBanner()
+ {
+ if (successInfoBar == null) return;
+
+ var resourceLoader = ResourceLoader.GetForCurrentView();
+ successInfoBar.Message = resourceLoader.GetString("AttachmentOverlay_SaveSuccessMessage");
+ successInfoBar.IsOpen = true;
+
+ // Auto-hide after 3 seconds
+ await System.Threading.Tasks.Task.Delay(3000);
+ successInfoBar.IsOpen = false;
+ }
}
}
\ No newline at end of file
diff --git a/Unicord.Universal/Resources/en-GB/Resources.resw b/Unicord.Universal/Resources/en-GB/Resources.resw
index d593b716..ac79242c 100644
--- a/Unicord.Universal/Resources/en-GB/Resources.resw
+++ b/Unicord.Universal/Resources/en-GB/Resources.resw
@@ -159,4 +159,37 @@
Verify your identity to open settings!
+
+ Toggle zoom mode
+
+
+ Zoom in
+
+
+ Zoom out
+
+
+ More
+
+
+ Save as
+
+
+ Share
+
+
+ Open in browser
+
+
+ Copy images
+
+
+ Copy link
+
+
+ Copy attachment ID
+
+
+ File saved successfully
+
\ No newline at end of file
diff --git a/Unicord.Universal/Resources/en-US/Resources.resw b/Unicord.Universal/Resources/en-US/Resources.resw
index d593b716..ac79242c 100644
--- a/Unicord.Universal/Resources/en-US/Resources.resw
+++ b/Unicord.Universal/Resources/en-US/Resources.resw
@@ -159,4 +159,37 @@
Verify your identity to open settings!
+
+ Toggle zoom mode
+
+
+ Zoom in
+
+
+ Zoom out
+
+
+ More
+
+
+ Save as
+
+
+ Share
+
+
+ Open in browser
+
+
+ Copy images
+
+
+ Copy link
+
+
+ Copy attachment ID
+
+
+ File saved successfully
+
\ No newline at end of file
diff --git a/Unicord.Universal/Resources/fr/Resources.resw b/Unicord.Universal/Resources/fr/Resources.resw
index 5d436367..9ebc618f 100644
--- a/Unicord.Universal/Resources/fr/Resources.resw
+++ b/Unicord.Universal/Resources/fr/Resources.resw
@@ -159,4 +159,37 @@
Vérifiez votre identité pour ouvrir les paramètres!
+
+ Basculer le mode zoom
+
+
+ Zoom avant
+
+
+ Zoom arrière
+
+
+ Plus
+
+
+ Enregistrer sous
+
+
+ Partager
+
+
+ Ouvrir dans le navigateur
+
+
+ Copier les images
+
+
+ Copier le lien
+
+
+ Copier l'ID de la pièce jointe
+
+
+ Fichier enregistré avec succès
+
diff --git a/Unicord.Universal/Resources/ja-JP/Resources.resw b/Unicord.Universal/Resources/ja-JP/Resources.resw
index e243ef85..30aacb1e 100644
--- a/Unicord.Universal/Resources/ja-JP/Resources.resw
+++ b/Unicord.Universal/Resources/ja-JP/Resources.resw
@@ -159,4 +159,37 @@
設定を開くためには認証が必要です!
+
+ ズームモードを切り替え
+
+
+ 拡大
+
+
+ 縮小
+
+
+ その他
+
+
+ 名前を付けて保存
+
+
+ 共有
+
+
+ ブラウザで開く
+
+
+ 画像をコピー
+
+
+ リンクをコピー
+
+
+ 添付ファイルIDをコピー
+
+
+ ファイルが正常に保存されました
+
diff --git a/Unicord.Universal/Resources/ru-RU/Resources.resw b/Unicord.Universal/Resources/ru-RU/Resources.resw
index 257d3afe..e35ba4f0 100644
--- a/Unicord.Universal/Resources/ru-RU/Resources.resw
+++ b/Unicord.Universal/Resources/ru-RU/Resources.resw
@@ -159,4 +159,37 @@
Подтвердите свою личность, чтобы открыть настройки!
+
+ Переключить режим масштабирования
+
+
+ Увеличить
+
+
+ Уменьшить
+
+
+ Дополнительно
+
+
+ Сохранить как
+
+
+ Поделиться
+
+
+ Открыть в браузере
+
+
+ Копировать изображения
+
+
+ Копировать ссылку
+
+
+ Копировать ID вложения
+
+
+ Файл успешно сохранён
+
\ No newline at end of file
diff --git a/Unicord.Universal/Themes/Styles/SunValley.xaml b/Unicord.Universal/Themes/Styles/SunValley.xaml
index fc74bfd7..c2a6b2a1 100644
--- a/Unicord.Universal/Themes/Styles/SunValley.xaml
+++ b/Unicord.Universal/Themes/Styles/SunValley.xaml
@@ -135,12 +135,12 @@
1
- 2
+ 8
1
- 4
+ 8