diff --git a/Setup/Assets/Themes/Kobe-Light/Compare.svg b/Setup/Assets/Themes/Kobe-Light/Compare.svg new file mode 100644 index 000000000..bcbd6a41a --- /dev/null +++ b/Setup/Assets/Themes/Kobe-Light/Compare.svg @@ -0,0 +1 @@ + diff --git a/Setup/Assets/Themes/Kobe-Light/CompareSlider.svg b/Setup/Assets/Themes/Kobe-Light/CompareSlider.svg new file mode 100644 index 000000000..92bfb01a3 --- /dev/null +++ b/Setup/Assets/Themes/Kobe-Light/CompareSlider.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Setup/Assets/Themes/Kobe-Light/igtheme.json b/Setup/Assets/Themes/Kobe-Light/igtheme.json index c63acb547..01e4e664e 100644 --- a/Setup/Assets/Themes/Kobe-Light/igtheme.json +++ b/Setup/Assets/Themes/Kobe-Light/igtheme.json @@ -16,6 +16,7 @@ "IsShowTitlebarLogo": true, "NavButtonLeft": "ViewPreviousImage.svg", "NavButtonRight": "ViewNextImage.svg", + "CompareSliderHandle": "CompareSlider.svg", "PreviewImage": "preview.webp", "AppLogo": "Logo.svg" }, @@ -47,6 +48,7 @@ "AutoZoom": "AutoZoom.svg", "Checkerboard": "Checkerboard.svg", "ColorPicker": "ColorPicker.svg", + "Compare": "Compare.svg", "Crop": "Crop.svg", "Delete": "Delete.svg", "Edit": "Edit.svg", diff --git a/Setup/Assets/Themes/Kobe/Compare.svg b/Setup/Assets/Themes/Kobe/Compare.svg new file mode 100644 index 000000000..91484bcd2 --- /dev/null +++ b/Setup/Assets/Themes/Kobe/Compare.svg @@ -0,0 +1 @@ + diff --git a/Setup/Assets/Themes/Kobe/CompareSlider.svg b/Setup/Assets/Themes/Kobe/CompareSlider.svg new file mode 100644 index 000000000..c9e857fd5 --- /dev/null +++ b/Setup/Assets/Themes/Kobe/CompareSlider.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Setup/Assets/Themes/Kobe/igtheme.json b/Setup/Assets/Themes/Kobe/igtheme.json index 6bae91244..eb323e461 100644 --- a/Setup/Assets/Themes/Kobe/igtheme.json +++ b/Setup/Assets/Themes/Kobe/igtheme.json @@ -16,6 +16,7 @@ "IsShowTitlebarLogo": true, "NavButtonLeft": "ViewPreviousImage.svg", "NavButtonRight": "ViewNextImage.svg", + "CompareSliderHandle": "CompareSlider.svg", "PreviewImage": "preview.webp", "AppLogo": "Logo.svg" }, @@ -37,6 +38,7 @@ "AutoZoom": "AutoZoom.svg", "Checkerboard": "Checkerboard.svg", "ColorPicker": "ColorPicker.svg", + "Compare": "Compare.svg", "Crop": "Crop.svg", "Delete": "Delete.svg", "Edit": "Edit.svg", diff --git a/Source/Components/ImageGlass.Base/Language/IgLang.cs b/Source/Components/ImageGlass.Base/Language/IgLang.cs index 5d2cd2fd3..4866028d6 100644 --- a/Source/Components/ImageGlass.Base/Language/IgLang.cs +++ b/Source/Components/ImageGlass.Base/Language/IgLang.cs @@ -276,6 +276,7 @@ public Dictionary InitDefaultLanguage() { $"_._InvalidAction", "Invalid action" }, //v9.0 { $"_._InvalidAction._Transformation", "ImageGlass does not support rotation, flipping for this image." }, //v9.0 + { $"_._InvalidAction._ComparisonMode", "This feature is not available in comparison mode." }, //v9.1 { "_._UserAction._MenuNotFound", "Cannot find menu '{0}' to invoke the action" }, // v9.0 @@ -510,6 +511,7 @@ public Dictionary InitDefaultLanguage() { "FrmMain.MnuTools", "Tools" }, //v3.0 { "FrmMain.MnuColorPicker", "Color picker" }, //v5.0 { "FrmMain.MnuCropTool", "Crop image" }, // v7.6 + { "FrmMain.MnuCompareTool", "Compare images" }, // v9.1 { "FrmMain.MnuResizeTool", "Resize image" }, // v9.2 { "FrmMain.MnuFrameNav", "Frame navigation" }, // v7.5 { "FrmMain.MnuGetMoreTools", "Get more tools…" }, // v9.0 @@ -890,6 +892,13 @@ public Dictionary InitDefaultLanguage() #endregion // FrmCrop + #region FrmCompare + { "FrmCompare._DropToReplaceMainImage", "Drop to replace main image" }, // v9.1 + { "FrmCompare._DropToSetComparisonImage", "Drop to set comparison image" }, // v9.1 + { "FrmCompare._SelectImageToCompare", "Select an image to compare using the button below." }, // v9.1 + #endregion // FrmCompare + + #region FrmColorPicker { "FrmColorPicker.BtnSettings._Tooltip", "Open Color picker settings…" }, //v9.0 diff --git a/Source/Components/ImageGlass.Base/Photoing/Codecs/PhotoCodec.cs b/Source/Components/ImageGlass.Base/Photoing/Codecs/PhotoCodec.cs index 4daf11d4a..97b54aa28 100644 --- a/Source/Components/ImageGlass.Base/Photoing/Codecs/PhotoCodec.cs +++ b/Source/Components/ImageGlass.Base/Photoing/Codecs/PhotoCodec.cs @@ -866,7 +866,7 @@ private static (bool loadSuccessful, IgImgData result, string ext, MagickReadSet base64Content = fs.ReadToEnd(); } - if (result.CanAnimate) + if (result.CanAnimate && options?.FirstFrameOnly != true) { result.Source = BHelper.ToGdiPlusBitmapFromBase64(base64Content); } @@ -887,16 +887,16 @@ private static (bool loadSuccessful, IgImgData result, string ext, MagickReadSet try { // Note: Using WIC is much faster than using MagickImageCollection - if (result.CanAnimate) + if (result.CanAnimate && options?.FirstFrameOnly != true) { result.Source = BHelper.ToGdiPlusBitmap(filePath); } - // multiple frame - else if (result.FrameCount > 0) + // multiple frame (but not animating or FirstFrameOnly requested) + else if (result.FrameCount > 1 && options?.FirstFrameOnly != true) { result.Source = WicBitmapDecoder.Load(filePath); } - // single frame + // single frame or FirstFrameOnly requested else { result.Image = WicBitmapSource.Load(filePath); @@ -913,7 +913,7 @@ private static (bool loadSuccessful, IgImgData result, string ext, MagickReadSet { using var webp = new WebPWrapper(); - if (result.CanAnimate) + if (result.CanAnimate && options?.FirstFrameOnly != true) { var aniWebP = webp.AnimLoad(filePath); var frames = aniWebP.Select(frame => diff --git a/Source/Components/ImageGlass.Base/Types/Const.cs b/Source/Components/ImageGlass.Base/Types/Const.cs index c9649cc9e..3625d7de6 100644 --- a/Source/Components/ImageGlass.Base/Types/Const.cs +++ b/Source/Components/ImageGlass.Base/Types/Const.cs @@ -87,4 +87,9 @@ public static class Const /// public const string IMAGE_FORMATS = ".3fr;.apng;.ari;.arw;.avif;.b64;.bay;.bmp;.cap;.cr2;.cr3;.crw;.cur;.cut;.dcr;.dcs;.dds;.dib;.dng;.drf;.eip;.emf;.erf;.exif;.exr;.fff;.fits;.flif;.gif;.gifv;.gpr;.hdp;.hdr;.heic;.heif;.hif;.ico;.iiq;.jfif;.jp2;.jpe;.jpeg;.jpg;.jxl;.jxr;.k25;.kdc;.mdc;.mef;.mjpeg;.mos;.mrw;.nef;.nrw;.obm;.orf;.pbm;.pcx;.pef;.pgm;.png;.ppm;.psb;.psd;.ptx;.pxn;.qoi;.r3d;.raf;.raw;.rw2;.rwl;.rwz;.sr2;.srf;.srw;.svg;.tga;.tif;.tiff;.viff;.wdp;.webp;.wmf;.wpg;.x3f;.xbm;.xpm;.xv"; + /// + /// File extensions that may contain animation and should use WebView2 for rendering. + /// + public static readonly string[] ANIMATED_FORMATS = [".gif", ".gifv", ".webp", ".apng"]; + } \ No newline at end of file diff --git a/Source/Components/ImageGlass.Gallery/ImageGallery.cs b/Source/Components/ImageGlass.Gallery/ImageGallery.cs index 194738e4b..a0c2aa18f 100644 --- a/Source/Components/ImageGlass.Gallery/ImageGallery.cs +++ b/Source/Components/ImageGlass.Gallery/ImageGallery.cs @@ -764,6 +764,25 @@ public void ResetErrorImage() mErrorImageChanged = false; } + + /// + /// Gets or sets whether comparison mode is active (shows A/B indicators). + /// + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool ComparisonMode { get; set; } = false; + + /// + /// Gets or sets the index of the main comparison image (A). + /// + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public int ComparisonMainIndex { get; set; } = -1; + + /// + /// Gets or sets the file path of the comparison image (B). + /// + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public string ComparisonImagePath { get; set; } = string.Empty; + #endregion diff --git a/Source/Components/ImageGlass.Gallery/Renderer/StyleRenderer.cs b/Source/Components/ImageGlass.Gallery/Renderer/StyleRenderer.cs index 72281d7ff..b73295fdb 100644 --- a/Source/Components/ImageGlass.Gallery/Renderer/StyleRenderer.cs +++ b/Source/Components/ImageGlass.Gallery/Renderer/StyleRenderer.cs @@ -1026,6 +1026,99 @@ public virtual void DrawItem(Graphics g, ImageGalleryItem item, ItemState state, { ControlPaint.DrawFocusRectangle(g, bounds); } + + // Draw comparison A/B badges + if (ImageGalleryOwner.ComparisonMode) + { + DrawComparisonBadge(g, item, bounds); + } + } + + /// + /// Draws the A/B comparison badge for items in comparison mode. + /// + protected virtual void DrawComparisonBadge(Graphics g, ImageGalleryItem item, Rectangle bounds) + { + var isMainImage = item.Index == ImageGalleryOwner.ComparisonMainIndex; + var isCompareImage = !string.IsNullOrEmpty(ImageGalleryOwner.ComparisonImagePath) && + string.Equals(item.FilePath, ImageGalleryOwner.ComparisonImagePath, StringComparison.OrdinalIgnoreCase); + + if (!isMainImage && !isCompareImage) return; + + var colorA = Color.FromArgb(220, 59, 130, 246); // Blue + var colorB = Color.FromArgb(220, 34, 197, 94); // Green + + var badgePadding = 4; + var badgeHeight = 20; + + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + + using var font = new Font("Segoe UI", 9f, FontStyle.Bold); + using var format = new StringFormat + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center + }; + + if (isMainImage && isCompareImage) + { + // Split pill: A on left (blue), B on right (green) + var pillWidth = 36; + var pillRect = new Rectangle( + bounds.Right - pillWidth - badgePadding, + bounds.Top + badgePadding, + pillWidth, + badgeHeight); + + var halfWidth = pillWidth / 2; + var radius = badgeHeight / 2; + + // Draw left half (A - blue) with rounded left side + using var pathLeft = new System.Drawing.Drawing2D.GraphicsPath(); + pathLeft.AddArc(pillRect.Left, pillRect.Top, badgeHeight, badgeHeight, 90, 180); + pathLeft.AddLine(pillRect.Left + radius, pillRect.Top, pillRect.Left + halfWidth, pillRect.Top); + pathLeft.AddLine(pillRect.Left + halfWidth, pillRect.Top, pillRect.Left + halfWidth, pillRect.Bottom); + pathLeft.AddLine(pillRect.Left + halfWidth, pillRect.Bottom, pillRect.Left + radius, pillRect.Bottom); + pathLeft.CloseFigure(); + + using var brushA = new SolidBrush(colorA); + g.FillPath(brushA, pathLeft); + + // Draw right half (B - green) with rounded right side + using var pathRight = new System.Drawing.Drawing2D.GraphicsPath(); + pathRight.AddLine(pillRect.Left + halfWidth, pillRect.Top, pillRect.Right - radius, pillRect.Top); + pathRight.AddArc(pillRect.Right - badgeHeight, pillRect.Top, badgeHeight, badgeHeight, -90, 180); + pathRight.AddLine(pillRect.Right - radius, pillRect.Bottom, pillRect.Left + halfWidth, pillRect.Bottom); + pathRight.CloseFigure(); + + using var brushB = new SolidBrush(colorB); + g.FillPath(brushB, pathRight); + + // Draw letters + var leftRect = new RectangleF(pillRect.Left, pillRect.Top, halfWidth, badgeHeight); + var rightRect = new RectangleF(pillRect.Left + halfWidth, pillRect.Top, halfWidth, badgeHeight); + g.DrawString("A", font, Brushes.White, leftRect, format); + g.DrawString("B", font, Brushes.White, rightRect, format); + } + else + { + // Single circular badge + var badgeSize = 20; + var badgeRect = new Rectangle( + bounds.Right - badgeSize - badgePadding, + bounds.Top + badgePadding, + badgeSize, + badgeSize); + + var badgeText = isMainImage ? "A" : "B"; + var badgeColor = isMainImage ? colorA : colorB; + + using var brush = new SolidBrush(badgeColor); + g.FillEllipse(brush, badgeRect); + g.DrawString(badgeText, font, Brushes.White, badgeRect, format); + } + + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.Default; } /// diff --git a/Source/Components/ImageGlass.Settings/WebUI/DXCanvas_Webview2.html b/Source/Components/ImageGlass.Settings/WebUI/DXCanvas_Webview2.html index c89662e6f..9a86b33eb 100644 --- a/Source/Components/ImageGlass.Settings/WebUI/DXCanvas_Webview2.html +++ b/Source/Components/ImageGlass.Settings/WebUI/DXCanvas_Webview2.html @@ -26,6 +26,33 @@ + + diff --git a/Source/Components/ImageGlass.Settings/WebUI/src/DXCanvas_Webview2/HapplaBoxViewer.ts b/Source/Components/ImageGlass.Settings/WebUI/src/DXCanvas_Webview2/HapplaBoxViewer.ts index cbe8233cb..cbb95fe32 100644 --- a/Source/Components/ImageGlass.Settings/WebUI/src/DXCanvas_Webview2/HapplaBoxViewer.ts +++ b/Source/Components/ImageGlass.Settings/WebUI/src/DXCanvas_Webview2/HapplaBoxViewer.ts @@ -18,6 +18,9 @@ enum Web2BackendMsgNames { STOP_ANIMATIONS = 'STOP_ANIMATIONS', SET_MESSAGE = 'SET_MESSAGE', SET_NAVIGATION = 'SET_NAVIGATION', + SET_COMPARISON_MODE = 'SET_COMPARISON_MODE', + SET_COMPARISON_IMAGES = 'SET_COMPARISON_IMAGES', + SET_COMPARISON_SLIDER = 'SET_COMPARISON_SLIDER', } enum Web2FrontendMsgNames { @@ -27,13 +30,34 @@ enum Web2FrontendMsgNames { ON_CONTENT_SIZE_CHANGED = 'ON_CONTENT_SIZE_CHANGED', ON_FILE_DROP = 'ON_FILE_DROP', ON_NAV_CLICK = 'ON_NAV_CLICK', + ON_COMPARISON_SLIDER_CHANGED = 'ON_COMPARISON_SLIDER_CHANGED', + ON_COMPARISON_PANE_DROP = 'ON_COMPARISON_PANE_DROP', } const _transitionDuration = 300; +const _dragThreshold = 5; let _boxEl: HapplaBoxHTMLElement = undefined; let _zoomMode: ZoomMode = ZoomMode.AutoZoom; let _isPointerDown = false; let _navHitTest: 'left' | 'right' | '' = ''; +let _comparisonMode = false; +let _comparisonSliderPos = 0.5; +let _comparisonSliderHandleY = 0.5; +let _isDraggingSlider = false; +let _isPanning = false; +let _panStartX = 0; +let _panStartY = 0; +let _panStartPanX = 0; +let _panStartPanY = 0; +let _comparisonZoom = 1; +let _comparisonPanX = 0; +let _comparisonPanY = 0; +let _comparisonImageWidth = 0; +let _comparisonImageHeight = 0; +let _dropHighlightPane: 'left' | 'right' | null = null; + +let _dropToReplaceMainImageText = 'Drop to replace main image'; +let _dropToSetComparisonImageText = 'Drop to set comparison image'; export default class HapplaBoxViewer { @@ -68,6 +92,11 @@ export default class HapplaBoxViewer { on(Web2BackendMsgNames.STOP_ANIMATIONS, HapplaBoxViewer.onWeb2StopAnimationsRequested); on(Web2BackendMsgNames.SET_MESSAGE, HapplaBoxViewer.onWeb2SetMessage); on(Web2BackendMsgNames.SET_NAVIGATION, HapplaBoxViewer.onWeb2SetNavigation); + on(Web2BackendMsgNames.SET_COMPARISON_MODE, HapplaBoxViewer.onWeb2SetComparisonMode); + on(Web2BackendMsgNames.SET_COMPARISON_IMAGES, HapplaBoxViewer.onWeb2SetComparisonImages); + on(Web2BackendMsgNames.SET_COMPARISON_SLIDER, HapplaBoxViewer.onWeb2SetComparisonSlider); + + HapplaBoxViewer.initComparisonEvents(); } private static onFileDragEntered(e: DragEvent) { @@ -167,6 +196,9 @@ export default class HapplaBoxViewer { Delta: e.deltaY, X: e.pageX, Y: e.pageY, + AltKey: e.altKey, + CtrlKey: e.ctrlKey, + ShiftKey: e.shiftKey, } as IMouseEventArgs, true); } @@ -319,4 +351,648 @@ export default class HapplaBoxViewer { return ''; } + + + // #region Comparison mode methods + + private static initComparisonEvents() { + const layerEl = query('#layerComparison'); + const sliderEl = query('.comparison-slider', layerEl); + + // Slider drag events + sliderEl.addEventListener('pointerdown', HapplaBoxViewer.onComparisonSliderPointerDown); + document.addEventListener('pointermove', HapplaBoxViewer.onComparisonSliderPointerMove); + document.addEventListener('pointerup', HapplaBoxViewer.onComparisonSliderPointerUp); + + // Zoom with mouse wheel + layerEl.addEventListener('wheel', HapplaBoxViewer.onComparisonWheel, { passive: false }); + + // Pan with mouse drag on images + layerEl.addEventListener('pointerdown', HapplaBoxViewer.onComparisonPointerDown); + + layerEl.addEventListener('dragenter', HapplaBoxViewer.onComparisonDragEnter); + layerEl.addEventListener('dragover', HapplaBoxViewer.onComparisonDragOver); + layerEl.addEventListener('dragleave', HapplaBoxViewer.onComparisonDragLeave); + layerEl.addEventListener('drop', HapplaBoxViewer.onComparisonDrop); + + // Update slider position on window resize (image-based positioning depends on container size) + window.addEventListener('resize', () => { + if (_comparisonMode) { + HapplaBoxViewer.setComparisonSliderPosition(_comparisonSliderPos); + } + }); + + document.addEventListener('keydown', HapplaBoxViewer.onComparisonKeyDown); + } + + private static onComparisonKeyDown(e: KeyboardEvent) { + if (!_comparisonMode) return; + + // Backspace: Reset slider to center (both horizontal and vertical) + if (e.key === 'Backspace') { + e.preventDefault(); + HapplaBoxViewer.setComparisonSliderPosition(0.5, 0.5); + post(Web2FrontendMsgNames.ON_COMPARISON_SLIDER_CHANGED, { + SliderPosition: 0.5, + }, true); + } + + // Backtick (`): Reset zoom and pan + if (e.key === '`') { + e.preventDefault(); + _comparisonZoom = 1; + _comparisonPanX = 0; + _comparisonPanY = 0; + HapplaBoxViewer.updateComparisonTransform(); + } + } + + private static onComparisonSliderPointerDown(e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + _isDraggingSlider = true; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + } + + private static onComparisonSliderPointerMove(e: PointerEvent) { + if (!_isDraggingSlider) return; + e.preventDefault(); + + const layerEl = query('#layerComparison'); + const rect = layerEl.getBoundingClientRect(); + const screenX = e.clientX - rect.left; + const screenY = e.clientY - rect.top; + + // Convert screen X to image-space position (0-1) + const newPosX = HapplaBoxViewer.screenXToSliderPosition(screenX); + + // Convert screen Y to handle position (0-1) + const newPosY = HapplaBoxViewer.screenYToHandlePosition(screenY, rect.height); + + HapplaBoxViewer.setComparisonSliderPosition(newPosX, newPosY); + + // Notify C# of slider change + post(Web2FrontendMsgNames.ON_COMPARISON_SLIDER_CHANGED, { + SliderPosition: newPosX, + }, true); + } + + /** + * Converts screen Y coordinate to handle position (0-1). + */ + private static screenYToHandlePosition(screenY: number, containerHeight: number): number { + const HANDLE_PADDING = 20; + const usableHeight = containerHeight - 2 * HANDLE_PADDING; + if (usableHeight <= 0) return 0.5; + + const pos = (screenY - HANDLE_PADDING) / usableHeight; + return Math.max(0, Math.min(1, pos)); + } + + private static onComparisonSliderPointerUp(e: PointerEvent) { + if (_isDraggingSlider) { + _isDraggingSlider = false; + (e.target as HTMLElement).releasePointerCapture?.(e.pointerId); + } + } + + private static onComparisonWheel(e: WheelEvent) { + e.preventDefault(); + + // If modifier key is pressed, forward to C# for handling (e.g., Alt+scroll = browse images) + if (e.altKey || e.ctrlKey || e.shiftKey) { + HapplaBoxViewer.onMouseWheel(e); + return; + } + + const delta = e.deltaY > 0 ? 0.9 : 1.1; + _comparisonZoom = Math.max(0.1, Math.min(10, _comparisonZoom * delta)); + + // Re-constrain pan after zoom change (zooming out may invalidate current pan) + const constrained = HapplaBoxViewer.constrainComparisonPan(_comparisonPanX, _comparisonPanY); + _comparisonPanX = constrained.panX; + _comparisonPanY = constrained.panY; + + HapplaBoxViewer.updateComparisonTransform(); + } + + private static onComparisonPointerDown(e: PointerEvent) { + // Only handle if not on slider + if ((e.target as HTMLElement).closest('.comparison-slider')) return; + + // Store pan start state + _isPanning = true; + _panStartX = e.clientX; + _panStartY = e.clientY; + _panStartPanX = _comparisonPanX; + _panStartPanY = _comparisonPanY; + + // Add listeners for this pan session + document.addEventListener('pointermove', HapplaBoxViewer.onComparisonPanMove); + document.addEventListener('pointerup', HapplaBoxViewer.onComparisonPanUp); + document.addEventListener('pointercancel', HapplaBoxViewer.onComparisonPanUp); + } + + private static onComparisonPanMove(e: PointerEvent) { + if (!_isPanning) return; + + const dx = e.clientX - _panStartX; + const dy = e.clientY - _panStartY; + const hasDragged = Math.abs(dx) > _dragThreshold || Math.abs(dy) > _dragThreshold; + + if (hasDragged) { + const newPanX = _panStartPanX + dx; + const newPanY = _panStartPanY + dy; + + // Apply constrained panning + const constrained = HapplaBoxViewer.constrainComparisonPan(newPanX, newPanY); + _comparisonPanX = constrained.panX; + _comparisonPanY = constrained.panY; + HapplaBoxViewer.updateComparisonTransform(); + } + } + + /** + * Constrains pan values based on zoom level and image/container dimensions. + * Only allows panning when the scaled image is larger than the container. + */ + private static constrainComparisonPan(panX: number, panY: number): { panX: number, panY: number } { + const layerEl = query('#layerComparison'); + const containerRect = layerEl.getBoundingClientRect(); + + const scaledWidth = _comparisonImageWidth * _comparisonZoom; + const scaledHeight = _comparisonImageHeight * _comparisonZoom; + + let constrainedPanX = panX; + let constrainedPanY = panY; + + // Only allow horizontal panning if image is wider than container + if (scaledWidth <= containerRect.width) { + constrainedPanX = 0; + } else { + // Clamp so image edges don't go past container center + const maxPanX = (scaledWidth - containerRect.width) / 2; + constrainedPanX = Math.max(-maxPanX, Math.min(maxPanX, panX)); + } + + // Only allow vertical panning if image is taller than container + if (scaledHeight <= containerRect.height) { + constrainedPanY = 0; + } else { + // Clamp so image edges don't go past container center + const maxPanY = (scaledHeight - containerRect.height) / 2; + constrainedPanY = Math.max(-maxPanY, Math.min(maxPanY, panY)); + } + + return { panX: constrainedPanX, panY: constrainedPanY }; + } + + private static onComparisonPanUp(e: PointerEvent) { + if (!_isPanning) return; + _isPanning = false; + + // Remove listeners + document.removeEventListener('pointermove', HapplaBoxViewer.onComparisonPanMove); + document.removeEventListener('pointerup', HapplaBoxViewer.onComparisonPanUp); + document.removeEventListener('pointercancel', HapplaBoxViewer.onComparisonPanUp); + + // Check if this was a click (not a drag) + const dx = e.clientX - _panStartX; + const dy = e.clientY - _panStartY; + const wasDrag = Math.abs(dx) > _dragThreshold || Math.abs(dy) > _dragThreshold; + + // If it was a click (not a drag), move the slider to that position + if (!wasDrag) { + const layerEl = query('#layerComparison'); + const rect = layerEl.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const newPos = HapplaBoxViewer.screenXToSliderPosition(clickX); + HapplaBoxViewer.setComparisonSliderPosition(newPos); + + post(Web2FrontendMsgNames.ON_COMPARISON_SLIDER_CHANGED, { + SliderPosition: newPos, + }, true); + } + } + + private static onComparisonDragEnter(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + // Only show highlight if dragging files + if (!e.dataTransfer?.types?.includes('Files')) return; + + const pane = HapplaBoxViewer.getDropTargetPane(e); + HapplaBoxViewer.setDropHighlight(pane); + } + + private static onComparisonDragOver(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + + // Update highlight based on current position + const pane = HapplaBoxViewer.getDropTargetPane(e); + if (_dropHighlightPane !== pane) { + HapplaBoxViewer.setDropHighlight(pane); + } + } + + private static onComparisonDragLeave(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + const layerEl = query('#layerComparison'); + const relatedTarget = e.relatedTarget as Node | null; + + // Check if we're leaving to outside the layer + if (!relatedTarget || !layerEl.contains(relatedTarget)) { + HapplaBoxViewer.clearDropHighlight(); + } + } + + private static onComparisonDrop(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + // Clear highlight first + HapplaBoxViewer.clearDropHighlight(); + + const pane = HapplaBoxViewer.getDropTargetPane(e); + + if (e.dataTransfer?.files?.length > 0) { + const message = { + name: Web2FrontendMsgNames.ON_COMPARISON_PANE_DROP, + data: JSON.stringify({ Pane: pane }), + }; + + console.info('🔵 Calling webview.postMessageWithAdditionalObjects(): ', + Web2FrontendMsgNames.ON_COMPARISON_PANE_DROP, pane, e.dataTransfer.files); + + // @ts-ignore + window.chrome.webview?.postMessageWithAdditionalObjects(message, e.dataTransfer.files); + } + } + + /** + * Gets the target pane ('left' or 'right') for a drag event based on cursor position. + */ + private static getDropTargetPane(e: DragEvent): 'left' | 'right' { + const layerEl = query('#layerComparison'); + const rect = layerEl.getBoundingClientRect(); + const x = e.clientX - rect.left; + const sliderX = HapplaBoxViewer.sliderPositionToScreenX(_comparisonSliderPos); + return x < sliderX ? 'left' : 'right'; + } + + /** + * Sets the drop highlight on the specified pane. + */ + private static setDropHighlight(pane: 'left' | 'right') { + _dropHighlightPane = pane; + + const layerEl = query('#layerComparison'); + const leftOverlay = query('.comparison-drop-overlay-left', layerEl); + const rightOverlay = query('.comparison-drop-overlay-right', layerEl); + const leftText = query('.comparison-drop-text', leftOverlay); + const rightText = query('.comparison-drop-text', rightOverlay); + + // Update text content + leftText.textContent = _dropToReplaceMainImageText; + rightText.textContent = _dropToSetComparisonImageText; + + // Show/hide overlays + leftOverlay.classList.toggle('is--visible', pane === 'left'); + rightOverlay.classList.toggle('is--visible', pane === 'right'); + + // Update overlay positions based on slider + HapplaBoxViewer.updateDropOverlayPositions(); + } + + /** + * Clears all drop highlights. + */ + private static clearDropHighlight() { + _dropHighlightPane = null; + + const layerEl = query('#layerComparison'); + const leftOverlay = query('.comparison-drop-overlay-left', layerEl); + const rightOverlay = query('.comparison-drop-overlay-right', layerEl); + + leftOverlay.classList.remove('is--visible'); + rightOverlay.classList.remove('is--visible'); + } + + /** + * Updates drop overlay positions based on slider position. + */ + private static updateDropOverlayPositions() { + const layerEl = query('#layerComparison'); + const leftOverlay = query('.comparison-drop-overlay-left', layerEl); + const rightOverlay = query('.comparison-drop-overlay-right', layerEl); + const sliderX = HapplaBoxViewer.sliderPositionToScreenX(_comparisonSliderPos); + const bounds = HapplaBoxViewer.getComparisonImageBounds(); + + // Left overlay: from left edge to slider + leftOverlay.style.left = '0'; + leftOverlay.style.width = `${sliderX}px`; + + // Right overlay: from slider to right edge + rightOverlay.style.left = `${sliderX}px`; + rightOverlay.style.width = `${bounds.containerWidth - sliderX}px`; + } + + /** + * Gets the image bounds in screen coordinates (accounting for zoom and pan). + */ + private static getComparisonImageBounds() { + const layerEl = query('#layerComparison'); + const containerRect = layerEl.getBoundingClientRect(); + + const scaledWidth = _comparisonImageWidth * _comparisonZoom; + const scaledHeight = _comparisonImageHeight * _comparisonZoom; + + // Image is centered in container, then offset by pan + const imageX = (containerRect.width - scaledWidth) / 2 + _comparisonPanX; + const imageY = (containerRect.height - scaledHeight) / 2 + _comparisonPanY; + + return { + x: imageX, + y: imageY, + width: scaledWidth, + height: scaledHeight, + containerWidth: containerRect.width, + containerHeight: containerRect.height, + }; + } + + /** + * Converts screen X coordinate to slider position (0-1 in image space). + */ + private static screenXToSliderPosition(screenX: number): number { + const bounds = HapplaBoxViewer.getComparisonImageBounds(); + if (bounds.width > 0) { + const pos = (screenX - bounds.x) / bounds.width; + return Math.max(0, Math.min(1, pos)); + } + return 0.5; + } + + /** + * Converts slider position (0-1 in image space) to screen X coordinate. + */ + private static sliderPositionToScreenX(pos: number): number { + const bounds = HapplaBoxViewer.getComparisonImageBounds(); + if (bounds.width > 0) { + return bounds.x + bounds.width * pos; + } + return bounds.containerWidth / 2; + } + + private static setComparisonSliderPosition(pos: number, handleY?: number) { + _comparisonSliderPos = pos; + if (handleY !== undefined) { + _comparisonSliderHandleY = handleY; + } + + const layerEl = query('#layerComparison'); + const sliderEl = query('.comparison-slider', layerEl); + const sliderHandleEl = query('.comparison-slider-handle', layerEl); + const leftImageEl = query('.comparison-image-left', layerEl); + const rightImageEl = query('.comparison-image-right', layerEl); + + const bounds = HapplaBoxViewer.getComparisonImageBounds(); + + // Calculate slider position in screen coordinates (image-based) + let sliderScreenX = HapplaBoxViewer.sliderPositionToScreenX(pos); + + // Out of bounds protection: clamp slider to stay within visible viewport + // with padding so the handle is always grabbable + const SLIDER_PADDING = 20; + const minX = SLIDER_PADDING; + const maxX = bounds.containerWidth - SLIDER_PADDING; + const clampedSliderX = Math.max(minX, Math.min(maxX, sliderScreenX)); + + sliderEl.style.left = `${clampedSliderX}px`; + + // Update handle vertical position + const HANDLE_PADDING = 20; + const usableHeight = bounds.containerHeight - 2 * HANDLE_PADDING; + const handleScreenY = HANDLE_PADDING + usableHeight * _comparisonSliderHandleY; + const clampedHandleY = Math.max(HANDLE_PADDING, Math.min(bounds.containerHeight - HANDLE_PADDING, handleScreenY)); + sliderHandleEl.style.top = `${clampedHandleY}px`; + + const clipLeft = sliderScreenX; + const clipRight = bounds.containerWidth - sliderScreenX; + // inset(top right bottom left) + leftImageEl.style.clipPath = `inset(0 ${clipRight}px 0 0)`; + rightImageEl.style.clipPath = `inset(0 0 0 ${clipLeft}px)`; + } + + private static updateComparisonTransform() { + const layerEl = query('#layerComparison'); + const leftEl = layerEl.querySelector('#compareImageLeft') as HTMLElement | SVGSVGElement; + const rightEl = layerEl.querySelector('#compareImageRight') as HTMLElement | SVGSVGElement; + + const transform = `translate(${_comparisonPanX}px, ${_comparisonPanY}px) scale(${_comparisonZoom})`; + if (leftEl) leftEl.style.transform = transform; + if (rightEl) rightEl.style.transform = transform; + + // Update slider position since image bounds changed (image-based positioning) + HapplaBoxViewer.setComparisonSliderPosition(_comparisonSliderPos); + } + + private static onWeb2SetComparisonMode(_: Web2BackendMsgNames, e: { + Enabled: boolean, + AccentColor: number[], + SliderHandleUrl: string, + }) { + _comparisonMode = e.Enabled; + + const layerEl = query('#layerComparison'); + layerEl.hidden = !e.Enabled; + + // Hide normal viewer when in comparison mode + if (_boxEl) { + _boxEl.hidden = e.Enabled; + _boxEl.style.display = e.Enabled ? 'none' : ''; + } + + if (e.Enabled) { + // Reset state + _comparisonZoom = 1; + _comparisonPanX = 0; + _comparisonPanY = 0; + _comparisonSliderPos = 0.5; + _comparisonSliderHandleY = 0.5; + HapplaBoxViewer.setComparisonSliderPosition(0.5, 0.5); + HapplaBoxViewer.updateComparisonTransform(); + + layerEl.focus(); + + // Set accent color + if (e.AccentColor) { + const [r, g, b] = e.AccentColor; + layerEl.style.setProperty('--accent-color', `rgb(${r}, ${g}, ${b})`); + } + + // Set slider handle icon from theme (using background-image for security) + const handleEl = query('.comparison-slider-handle', layerEl); + if (e.SliderHandleUrl) { + // Use CSS background-image instead of innerHTML for security + handleEl.style.backgroundImage = `url("${e.SliderHandleUrl}")`; + handleEl.style.backgroundSize = 'contain'; + handleEl.style.backgroundPosition = 'center'; + handleEl.style.backgroundRepeat = 'no-repeat'; + handleEl.style.backgroundColor = 'transparent'; + // Hide the default SVG content + const svgEl = handleEl.querySelector('svg'); + if (svgEl) svgEl.style.display = 'none'; + } else { + // Show default SVG arrows with accent color background + handleEl.style.backgroundImage = ''; + handleEl.style.backgroundColor = ''; + const svgEl = handleEl.querySelector('svg'); + if (svgEl) svgEl.style.display = ''; + } + } + } + + /** + * Gets dimensions from an SVG element (from attributes or viewBox). + */ + private static getSvgDimensions(svg: SVGSVGElement): { width: number, height: number } { + let width = parseFloat(svg.getAttribute('width') || '0'); + let height = parseFloat(svg.getAttribute('height') || '0'); + + if (width === 0 || height === 0) { + const viewBox = svg.getAttribute('viewBox'); + if (viewBox) { + const parts = viewBox.split(/[\s,]+/); + if (parts.length >= 4) { + width = parseFloat(parts[2]) || 0; + height = parseFloat(parts[3]) || 0; + } + } + } + + return { width, height }; + } + + /** + * Updates comparison image dimensions for image-based slider positioning. + */ + private static updateComparisonImageDimensions() { + const layerEl = query('#layerComparison'); + const leftEl = layerEl.querySelector('#compareImageLeft') as HTMLImageElement | SVGSVGElement; + + if (!leftEl) return; + + if (leftEl instanceof SVGSVGElement) { + const dims = HapplaBoxViewer.getSvgDimensions(leftEl); + _comparisonImageWidth = dims.width; + _comparisonImageHeight = dims.height; + } else if (leftEl instanceof HTMLImageElement) { + _comparisonImageWidth = leftEl.naturalWidth || leftEl.width; + _comparisonImageHeight = leftEl.naturalHeight || leftEl.height; + } + + // Update slider position with new dimensions + HapplaBoxViewer.setComparisonSliderPosition(_comparisonSliderPos); + } + + private static onWeb2SetComparisonImages(_: Web2BackendMsgNames, e: { + LeftImageUrl: string, + LeftImageHtml: string, + RightImageUrl: string, + RightImageHtml: string, + }) { + const layerEl = query('#layerComparison'); + const leftContainer = query('.comparison-image-left', layerEl); + const rightContainer = query('.comparison-image-right', layerEl); + + const configureSvg = (svg: SVGSVGElement, id: string) => { + svg.id = id; + svg.style.maxWidth = 'none'; + svg.style.maxHeight = 'none'; + svg.style.overflow = 'visible'; + + // Ensure SVG has proper dimensions for scaling + // If no width/height, use viewBox or set defaults + if (!svg.hasAttribute('width') && !svg.hasAttribute('height')) { + const viewBox = svg.getAttribute('viewBox'); + if (viewBox) { + const parts = viewBox.split(/[\s,]+/); + if (parts.length >= 4) { + svg.setAttribute('width', parts[2]); + svg.setAttribute('height', parts[3]); + } + } + } + }; + + const configureImg = (img: HTMLImageElement, id: string) => { + img.id = id; + img.style.maxWidth = 'none'; + img.style.maxHeight = 'none'; + img.onload = () => HapplaBoxViewer.updateComparisonImageDimensions(); + // If already loaded (cached), update immediately + if (img.complete && img.naturalWidth > 0) { + HapplaBoxViewer.updateComparisonImageDimensions(); + } + }; + + // Handle left image (could be URL or HTML for SVG/raster) + if (e.LeftImageHtml) { + // HTML content - could be SVG or base64 img tag (trusted local content) + leftContainer.innerHTML = e.LeftImageHtml; + const svg = leftContainer.querySelector('svg') as SVGSVGElement; + const img = leftContainer.querySelector('img') as HTMLImageElement; + if (svg) { + configureSvg(svg, 'compareImageLeft'); + HapplaBoxViewer.updateComparisonImageDimensions(); + } else if (img) { + configureImg(img, 'compareImageLeft'); + } + } else if (e.LeftImageUrl) { + leftContainer.innerHTML = `Image A`; + const img = leftContainer.querySelector('img') as HTMLImageElement; + if (img) { + configureImg(img, 'compareImageLeft'); + } + } + + // Handle right image + if (e.RightImageHtml) { + rightContainer.innerHTML = e.RightImageHtml; + const svg = rightContainer.querySelector('svg') as SVGSVGElement; + const img = rightContainer.querySelector('img') as HTMLImageElement; + if (svg) { + configureSvg(svg, 'compareImageRight'); + } else if (img) { + configureImg(img, 'compareImageRight'); + } + } else if (e.RightImageUrl) { + rightContainer.innerHTML = `Image B`; + const img = rightContainer.querySelector('img') as HTMLImageElement; + if (img) { + configureImg(img, 'compareImageRight'); + } + } + + // Reset transform when images change to avoid "stuck in previous cutout" issue + _comparisonZoom = 1; + _comparisonPanX = 0; + _comparisonPanY = 0; + + HapplaBoxViewer.updateComparisonTransform(); + } + + private static onWeb2SetComparisonSlider(_: Web2BackendMsgNames, e: { + Position: number, + }) { + HapplaBoxViewer.setComparisonSliderPosition(e.Position); + } + + // #endregion } diff --git a/Source/Components/ImageGlass.Settings/WebUI/src/DXCanvas_Webview2/webComponents/happlajs/HapplaBoxTypes.ts b/Source/Components/ImageGlass.Settings/WebUI/src/DXCanvas_Webview2/webComponents/happlajs/HapplaBoxTypes.ts index 5aadda608..8f0b9f8ab 100644 --- a/Source/Components/ImageGlass.Settings/WebUI/src/DXCanvas_Webview2/webComponents/happlajs/HapplaBoxTypes.ts +++ b/Source/Components/ImageGlass.Settings/WebUI/src/DXCanvas_Webview2/webComponents/happlajs/HapplaBoxTypes.ts @@ -7,6 +7,9 @@ export type IMouseEventArgs = { Y: number; Delta: number; NavigationButton?: 'left' | 'right' | ''; + AltKey?: boolean; + CtrlKey?: boolean; + ShiftKey?: boolean; }; export type IZoomEventArgs = { diff --git a/Source/Components/ImageGlass.Settings/WebUI/src/styles/components/layer-comparison.scss b/Source/Components/ImageGlass.Settings/WebUI/src/styles/components/layer-comparison.scss new file mode 100644 index 000000000..0f8432192 --- /dev/null +++ b/Source/Components/ImageGlass.Settings/WebUI/src/styles/components/layer-comparison.scss @@ -0,0 +1,163 @@ +#layerComparison { + position: fixed; + inset: 0; + z-index: 100; + overflow: hidden; + background: transparent; + outline: none; + + &[hidden] { + display: none; + } +} + +.comparison-container { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.comparison-image { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + img, + svg { + max-width: none; + max-height: none; + transform-origin: center center; + user-select: none; + -webkit-user-drag: none; + } + + svg { + overflow: visible; + } +} + +.comparison-image-left { + z-index: 1; + // clip-path is set dynamically in JS to clip content right of the slider +} + +.comparison-image-right { + z-index: 2; + // clip-path is set dynamically in JS to clip content left of the slider +} + +.comparison-slider { + position: absolute; + // left is set dynamically in JS using pixel values for image-based positioning + top: 0; + bottom: 0; + z-index: 10; + width: 4px; + transform: translateX(-50%); + cursor: move; + touch-action: none; + + &::before { + content: ''; + position: absolute; + left: -10px; + right: -10px; + top: 0; + bottom: 0; + } +} + +.comparison-slider-line { + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 2px; + transform: translateX(-50%); + background: var(--accent-color, #0078d4); + box-shadow: 0 0 4px rgba(0, 0, 0, 0.5); +} + +.comparison-slider-handle { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent-color, #0078d4); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + cursor: move; + + svg { + width: 20px; + height: 20px; + color: white; + stroke: currentColor; + stroke-width: 2; + fill: none; + } + + &:hover { + transform: translate(-50%, -50%) scale(1.1); + } + + &:active { + transform: translate(-50%, -50%) scale(0.95); + } +} + +.comparison-drop-overlay { + position: absolute; + top: 0; + bottom: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + + &.is--visible { + opacity: 1; + } +} + +.comparison-drop-overlay-left, +.comparison-drop-overlay-right { + // Position and width are set dynamically in JS + background: rgba(0, 0, 0, 0.25); +} + +.comparison-drop-border { + position: absolute; + inset: 4px; + border: 3px solid var(--accent-color, #0078d4); + border-radius: 8px; + opacity: 0.8; +} + +.comparison-drop-text { + position: relative; + z-index: 1; + padding: 12px 20px; + background: rgba(0, 0, 0, 0.6); + border-radius: 6px; + color: white; + font-size: 14px; + font-weight: 500; + text-align: center; + max-width: 80%; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} diff --git a/Source/Components/ImageGlass.Settings/WebUI/src/styles/main.scss b/Source/Components/ImageGlass.Settings/WebUI/src/styles/main.scss index e3e2fe822..6e6143e03 100644 --- a/Source/Components/ImageGlass.Settings/WebUI/src/styles/main.scss +++ b/Source/Components/ImageGlass.Settings/WebUI/src/styles/main.scss @@ -32,6 +32,7 @@ @import "./components/list-horizontal.scss"; @import "./components/layer-message.scss"; @import "./components/layer-navigation.scss"; +@import "./components/layer-comparison.scss"; @import "./components/toolbar-editor.scss"; diff --git a/Source/Components/ImageGlass.UI/Renderers/ModernGalleryRenderer.cs b/Source/Components/ImageGlass.UI/Renderers/ModernGalleryRenderer.cs index f3e2c57b7..470d3946f 100644 --- a/Source/Components/ImageGlass.UI/Renderers/ModernGalleryRenderer.cs +++ b/Source/Components/ImageGlass.UI/Renderers/ModernGalleryRenderer.cs @@ -209,6 +209,12 @@ public override void DrawItem(Graphics g, ImageGalleryItem item, ItemState state #endregion + + // Draw comparison A/B badges + if (ImageGalleryOwner.ComparisonMode) + { + DrawComparisonBadge(g, item, bounds); + } } diff --git a/Source/Components/ImageGlass.UI/Themes/IgTheme.cs b/Source/Components/ImageGlass.UI/Themes/IgTheme.cs index 8e7bccf85..a4efe3550 100644 --- a/Source/Components/ImageGlass.UI/Themes/IgTheme.cs +++ b/Source/Components/ImageGlass.UI/Themes/IgTheme.cs @@ -171,6 +171,28 @@ public string NavRightImagePath } } + /// + /// Gets the full path of comparison slider handle image. + /// + public string CompareSliderHandlePath + { + get + { + if (JsonModel == null) return string.Empty; + + if (JsonModel.Settings.TryGetValue(nameof(IgThemeSettings.CompareSliderHandle), out var imgObj)) + { + var imgName = imgObj.ToString(); + if (!string.IsNullOrWhiteSpace(imgName)) + { + return Path.Combine(FolderPath, imgName); + } + } + + return string.Empty; + } + } + /// /// Initializes theme pack and reads the theme config file. @@ -275,6 +297,15 @@ public void LoadThemeSettings() Settings.NavButtonRight = BHelper.ToWicBitmapSource(bmp); } + // CompareSliderHandle + if (JsonModel.Settings.TryGetValue(nameof(IgThemeSettings.CompareSliderHandle), out var compareSliderObject)) + { + var iconPath = Path.Combine(FolderPath, compareSliderObject?.ToString()); + using var bmp = await PhotoCodec.GetThumbnailAsync(iconPath, navBtnSize, navBtnSize); + + Settings.CompareSliderHandle = BHelper.ToWicBitmapSource(bmp); + } + // AppLogo if (JsonModel.Settings.TryGetValue(nameof(IgThemeSettings.AppLogo), out var appLogoObject)) { diff --git a/Source/Components/ImageGlass.UI/Themes/IgThemeModels.cs b/Source/Components/ImageGlass.UI/Themes/IgThemeModels.cs index 4a61cdf43..c4a682f35 100644 --- a/Source/Components/ImageGlass.UI/Themes/IgThemeModels.cs +++ b/Source/Components/ImageGlass.UI/Themes/IgThemeModels.cs @@ -117,6 +117,9 @@ protected virtual void Dispose(bool disposing) NavButtonRight?.Dispose(); NavButtonRight = null; + CompareSliderHandle?.Dispose(); + CompareSliderHandle = null; + AppLogo?.Dispose(); AppLogo = null; } @@ -154,6 +157,11 @@ public virtual void Dispose() /// public WicBitmapSource? NavButtonRight { get; set; } + /// + /// Gets, sets the comparison slider handle icon + /// + public WicBitmapSource? CompareSliderHandle { get; set; } + /// /// Sets, sets app logo /// @@ -175,6 +183,7 @@ public class IgThemeToolbarIcons : IDisposable public Bitmap? AutoZoom { get; set; } public Bitmap? Checkerboard { get; set; } public Bitmap? ColorPicker { get; set; } + public Bitmap? Compare { get; set; } public Bitmap? Crop { get; set; } public Bitmap? Delete { get; set; } public Bitmap? Edit { get; set; } @@ -233,6 +242,9 @@ protected virtual void Dispose(bool disposing) ColorPicker?.Dispose(); ColorPicker = null; + Compare?.Dispose(); + Compare = null; + Crop?.Dispose(); Crop = null; diff --git a/Source/Components/ImageGlass.Views/Enums.cs b/Source/Components/ImageGlass.Views/Enums.cs index aa5469601..948a3b48d 100644 --- a/Source/Components/ImageGlass.Views/Enums.cs +++ b/Source/Components/ImageGlass.Views/Enums.cs @@ -136,6 +136,10 @@ public static class Web2BackendMsgNames public static string SET_MESSAGE => "SET_MESSAGE"; public static string SET_NAVIGATION => "SET_NAVIGATION"; + // Comparison mode + public static string SET_COMPARISON_MODE => "SET_COMPARISON_MODE"; + public static string SET_COMPARISON_IMAGES => "SET_COMPARISON_IMAGES"; + public static string SET_COMPARISON_SLIDER => "SET_COMPARISON_SLIDER"; } @@ -148,4 +152,7 @@ public static class Web2FrontendMsgNames public static string ON_FILE_DROP => "ON_FILE_DROP"; public static string ON_NAV_CLICK => "ON_NAV_CLICK"; + // Comparison mode + public static string ON_COMPARISON_SLIDER_CHANGED => "ON_COMPARISON_SLIDER_CHANGED"; + public static string ON_COMPARISON_PANE_DROP => "ON_COMPARISON_PANE_DROP"; } diff --git a/Source/Components/ImageGlass.Views/ViewerCanvas.cs b/Source/Components/ImageGlass.Views/ViewerCanvas.cs index bbd351729..f2187e2b0 100644 --- a/Source/Components/ImageGlass.Views/ViewerCanvas.cs +++ b/Source/Components/ImageGlass.Views/ViewerCanvas.cs @@ -529,7 +529,14 @@ private set if (_web2 != null) { - _ = _web2.SetWeb2VisibilityAsync(value == ImageSource.Webview2); + // WebView2 visible for SVG source or SVG comparison mode + var shouldBeVisible = value == ImageSource.Webview2 || IsWeb2ComparisonModeActive; + _ = _web2.SetWeb2VisibilityAsync(shouldBeVisible); + + if (IsWeb2ComparisonModeActive && value != ImageSource.Webview2) + { + _web2.BringToFront(); + } } } } @@ -1086,9 +1093,12 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); DisposeImageResources(); + DisposeCompareImageResources(); DXHelper.DisposeD2D1Bitmap(ref _d2dNavLeftImage); DXHelper.DisposeD2D1Bitmap(ref _d2dNavRightImage); + _d2dCompareSliderHandle?.Dispose(); + _d2dCompareSliderHandle = null; DisposeCheckerboardBrushes(); @@ -1114,6 +1124,13 @@ protected override void OnDeviceCreated(DeviceCreatedReason reason) // dispose the Direct2D image DXHelper.DisposeD2D1Bitmap(ref _d2dImage); + + // dispose and recreate comparison D2D image if we have a WIC source + DXHelper.DisposeD2D1Bitmap(ref _d2dCompareImage); + if (_wicCompareImage != null) + { + _d2dCompareImage = DXHelper.ToD2D1Bitmap(Device, _wicCompareImage); + } } } @@ -1134,6 +1151,12 @@ protected override void OnMouseDown(MouseEventArgs e) base.OnMouseDown(e); if (!IsReady) return; + // Handle comparison mode interactions first + if (_comparisonMode && HandleComparisonMouseDown(e)) + { + return; + } + _mouseDownButton = e.Button; _isMouseDragged = false; _mouseDownPoint = e.Location; @@ -1216,6 +1239,15 @@ protected override void OnMouseUp(MouseEventArgs e) base.OnMouseUp(e); if (!IsReady) return; + // Handle comparison mode interactions first + if (_comparisonMode && HandleComparisonMouseUp(e)) + { + // Reset mouse state to prevent stuck panning + _mouseDownButton = MouseButtons.None; + _mouseDownPoint = null; + return; + } + // Distinguish between clicks #region Distinguish between clicks @@ -1324,6 +1356,12 @@ protected override void OnMouseMove(MouseEventArgs e) base.OnMouseMove(e); if (!IsReady) return; + // Handle comparison mode interactions first + if (_comparisonMode && HandleComparisonMouseMove(e)) + { + return; + } + var canSelect = EnableSelection && _mouseDownButton == MouseButtons.Left; var requestRerender = false; _mouseMovePoint = e.Location; @@ -1567,8 +1605,15 @@ protected override void OnRender(DXGraphics g) DrawCheckerboardLayer(g); - // draw image layer - DrawImageLayer(g); + // draw image layer (or comparison layer if in comparison mode) + if (_comparisonMode) + { + DrawComparisonLayer(g); + } + else + { + DrawImageLayer(g); + } // emits event ImageDrawn diff --git a/Source/Components/ImageGlass.Views/ViewerCanvas_Comparison.cs b/Source/Components/ImageGlass.Views/ViewerCanvas_Comparison.cs new file mode 100644 index 000000000..01865770c --- /dev/null +++ b/Source/Components/ImageGlass.Views/ViewerCanvas_Comparison.cs @@ -0,0 +1,841 @@ +/* +ImageGlass Project - Image viewer for Windows +Copyright (C) 2010 - 2026 DUONG DIEU PHAP +Project homepage: https://imageglass.org + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +using D2Phap.DXControl; +using DirectN; +using ImageGlass.Base; +using ImageGlass.Base.Photoing.Codecs; +using ImageGlass.Base.WinApi; +using System.ComponentModel; +using WicNet; + +namespace ImageGlass.Viewer; + +/// +/// ViewerCanvas partial class for image comparison mode. +/// +public partial class ViewerCanvas +{ + // Comparison mode private fields + #region Comparison mode private fields + + private bool _comparisonMode = false; + private float _comparisonSliderPos = 0.5f; + private float _comparisonSliderHandleY = 0.5f; + private bool _isDraggingComparisonSlider = false; + private ComparisonPaneHover _comparisonPaneHover = ComparisonPaneHover.None; + + // Comparison image resources + private WicBitmapSource? _wicCompareImage; + private IComObject? _d2dCompareImage; + private string _compareImagePath = string.Empty; + private float _compareImageWidth = 0; + private float _compareImageHeight = 0; + + // Slider handle icon resources + private WicBitmapSource? _wicCompareSliderHandle; + private IComObject? _d2dCompareSliderHandle; + + // Slider appearance (base values before DPI scaling) + private const float SLIDER_LINE_HIT_WIDTH_BASE = 20f; + private const float SLIDER_LINE_WIDTH_BASE = 2f; + private const float SLIDER_HANDLE_SIZE_BASE = 20f; + private const float SLIDER_HANDLE_HIT_SIZE_BASE = 26f; + + // Drop highlight state + private ComparisonPaneHover _dropHighlightPane = ComparisonPaneHover.None; + + #endregion + + + // Comparison mode public properties + #region Comparison mode public properties + + /// + /// Gets or sets whether comparison mode is enabled. + /// + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool ComparisonMode + { + get => _comparisonMode; + set + { + if (_comparisonMode != value) + { + _comparisonMode = value; + Invalidate(); + ComparisonModeChanged?.Invoke(this, EventArgs.Empty); + } + } + } + + /// + /// Gets or sets the comparison slider position (0.0 to 1.0). + /// + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public float ComparisonSliderPosition + { + get => _comparisonSliderPos; + set + { + var clamped = Math.Max(0f, Math.Min(1f, value)); + if (Math.Abs(_comparisonSliderPos - clamped) > 0.001f) + { + _comparisonSliderPos = clamped; + Invalidate(); + ComparisonSliderChanged?.Invoke(this, EventArgs.Empty); + } + } + } + + /// + /// Gets the file path of the comparison image. + /// + public string CompareImagePath => _compareImagePath; + + /// + /// Gets whether a comparison image is loaded. + /// + public bool HasCompareImage => _wicCompareImage != null; + + /// + /// Gets which pane the mouse is currently hovering over. + /// + public ComparisonPaneHover ComparisonPaneHover => _comparisonPaneHover; + + /// + /// Gets the current slider X position in screen coordinates. + /// + public int ComparisonSliderScreenX => GetSliderScreenX(); + + /// + /// Gets or sets the vertical position of the slider handle (0.0 = top, 1.0 = bottom). + /// + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public float ComparisonSliderHandleY + { + get => _comparisonSliderHandleY; + set + { + var clamped = Math.Max(0f, Math.Min(1f, value)); + if (Math.Abs(_comparisonSliderHandleY - clamped) > 0.001f) + { + _comparisonSliderHandleY = clamped; + Invalidate(); + } + } + } + + /// + /// Gets or sets the comparison slider handle icon. + /// + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public WicBitmapSource? CompareSliderHandleImage + { + set + { + _wicCompareSliderHandle = value; + _d2dCompareSliderHandle?.Dispose(); + _d2dCompareSliderHandle = null; + } + } + + /// + /// Gets or sets the text shown when dropping to replace the main image. + /// + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public string DropToReplaceMainImageText { get; set; } = "Drop to replace main image"; + + /// + /// Gets or sets the text shown when dropping to set the comparison image. + /// + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public string DropToSetComparisonImageText { get; set; } = "Drop to set comparison image"; + + #endregion + + + // Comparison mode events + #region Comparison mode events + + /// + /// Occurs when comparison mode is toggled. + /// + public event EventHandler? ComparisonModeChanged; + + /// + /// Occurs when the comparison slider position changes. + /// + public event EventHandler? ComparisonSliderChanged; + + /// + /// Occurs when a comparison pane is clicked. + /// + public event EventHandler? ComparisonPaneClicked; + + /// + /// Occurs when a file is dropped on a comparison pane. + /// + public event EventHandler? ComparisonPaneFileDrop; + + #endregion + + + // Comparison mode public methods + #region Comparison mode public methods + + /// + /// Sets the comparison image from image data. + /// + public void SetCompareImage(IgImgData? imgData, string filePath = "") + { + DisposeCompareImageResources(); + + if (imgData == null || imgData.IsImageNull) + { + _compareImagePath = string.Empty; + _compareImageWidth = 0; + _compareImageHeight = 0; + } + else + { + _compareImagePath = filePath; + _wicCompareImage = imgData.Image; + _d2dCompareImage = DXHelper.ToD2D1Bitmap(Device, _wicCompareImage); + + if (_wicCompareImage != null) + { + _compareImageWidth = _wicCompareImage.Width; + _compareImageHeight = _wicCompareImage.Height; + } + } + + Invalidate(); + } + + /// + /// Sets the comparison image from a WIC bitmap source. + /// + public void SetCompareImage(WicBitmapSource? image, string filePath = "") + { + DisposeCompareImageResources(); + + if (image == null) + { + _compareImagePath = string.Empty; + _compareImageWidth = 0; + _compareImageHeight = 0; + } + else + { + _compareImagePath = filePath; + _wicCompareImage = image; + _d2dCompareImage = DXHelper.ToD2D1Bitmap(Device, _wicCompareImage); + _compareImageWidth = image.Width; + _compareImageHeight = image.Height; + } + + Invalidate(); + } + + /// + /// Clears the comparison image. + /// + public void ClearCompareImage() + { + DisposeCompareImageResources(); + _compareImagePath = string.Empty; + _compareImageWidth = 0; + _compareImageHeight = 0; + Invalidate(); + } + + /// + /// Swaps the main image and comparison image. + /// + public void SwapCompareImages() + { + if (!_comparisonMode) return; + + (_wicImage, _wicCompareImage) = (_wicCompareImage, _wicImage); + (_d2dImage, _d2dCompareImage) = (_d2dCompareImage, _d2dImage); + + var tempWidth = SourceWidth; + var tempHeight = SourceHeight; + SourceWidth = _compareImageWidth; + SourceHeight = _compareImageHeight; + _compareImageWidth = tempWidth; + _compareImageHeight = tempHeight; + + Invalidate(); + } + + /// + /// Resets the comparison slider to center. + /// + public void ResetComparisonSlider() + { + ComparisonSliderPosition = 0.5f; + ComparisonSliderHandleY = 0.5f; + + if (IsWeb2ComparisonModeActive) + { + SetWeb2ComparisonSlider(0.5f); + } + } + + /// + /// Sets the drop highlight pane to show visual feedback during drag operations. + /// + public void SetComparisonDropHighlight(ComparisonPaneHover pane) + { + if (_dropHighlightPane != pane) + { + _dropHighlightPane = pane; + Invalidate(); + } + } + + /// + /// Clears the drop highlight visual feedback. + /// + public void ClearComparisonDropHighlight() + { + if (_dropHighlightPane != ComparisonPaneHover.None) + { + _dropHighlightPane = ComparisonPaneHover.None; + Invalidate(); + } + } + + /// + /// Gets the target pane for a drop at the given screen coordinates. + /// + public ComparisonPaneHover GetDropTargetPane(Point screenPoint) + { + if (!_comparisonMode) return ComparisonPaneHover.None; + + var clientPoint = PointToClient(screenPoint); + var sliderX = GetSliderScreenX(); + + return clientPoint.X < sliderX + ? ComparisonPaneHover.LeftPane + : ComparisonPaneHover.RightPane; + } + + #endregion + + + // Comparison mode private methods + #region Comparison mode private methods + + /// + /// Disposes comparison image resources. + /// + private void DisposeCompareImageResources() + { + _d2dCompareImage?.Dispose(); + _d2dCompareImage = null; + _wicCompareImage = null; + } + + /// + /// Gets which pane a point is in during comparison mode. + /// + public ComparisonPaneHover GetComparisonPaneAt(Point point) + { + if (!_comparisonMode) return ComparisonPaneHover.None; + + var sliderX = GetSliderScreenX(); + var handleX = GetSliderScreenXClamped(); + var handleY = GetSliderHandleScreenY(); + + // Check handle hit area (circular) + var handleHitSize = DpiApi.Scale(SLIDER_HANDLE_HIT_SIZE_BASE); + var dx = point.X - handleX; + var dy = point.Y - handleY; + var distSq = dx * dx + dy * dy; + var handleRadius = handleHitSize / 2f; + if (distSq <= handleRadius * handleRadius) + { + return ComparisonPaneHover.Slider; + } + + // Check line hit area (rectangular along the full height) + var lineHitWidth = DpiApi.Scale(SLIDER_LINE_HIT_WIDTH_BASE); + var lineHitLeft = sliderX - lineHitWidth / 2f; + var lineHitRight = sliderX + lineHitWidth / 2f; + if (point.X >= lineHitLeft && point.X <= lineHitRight) + { + return ComparisonPaneHover.Slider; + } + + return point.X < sliderX + ? ComparisonPaneHover.LeftPane + : ComparisonPaneHover.RightPane; + } + + /// + /// Gets the slider handle Y position in screen coordinates. + /// + private int GetSliderHandleScreenY() + { + var handlePadding = (int)DpiApi.Scale(20f); + var handleY = (int)(handlePadding + (Height - 2 * handlePadding) * _comparisonSliderHandleY); + return Math.Clamp(handleY, handlePadding, Height - handlePadding); + } + + /// + /// Converts slider position (0-1) to screen X coordinate. + /// Rounds to pixel boundary to avoid splitting pixels. + /// + private int GetSliderScreenX() + { + var virtualRect = GetVirtualImageRect(); + if (virtualRect.Width > 0) + { + // Round to nearest pixel boundary to avoid line cutting through pixels + return (int)MathF.Round(virtualRect.X + virtualRect.Width * _comparisonSliderPos); + } + + return Width / 2; + } + + /// + /// Gets the slider screen X clamped to visible bounds. + /// + private int GetSliderScreenXClamped() + { + var sliderX = GetSliderScreenX(); + var sliderPadding = (int)DpiApi.Scale(20f); + return Math.Clamp(sliderX, sliderPadding, Width - sliderPadding); + } + + /// + /// Converts screen X coordinate to slider position (0-1). + /// + private float ScreenXToSliderPosition(float screenX) + { + var virtualRect = GetVirtualImageRect(); + if (virtualRect.Width > 0) + { + var pos = (screenX - virtualRect.X) / virtualRect.Width; + return Math.Clamp(pos, 0f, 1f); + } + + return 0.5f; + } + + /// + /// Gets the full image rectangle in screen coordinates (unclipped by viewport). + /// + private RectangleF GetVirtualImageRect() + { + if (SourceWidth == 0 || SourceHeight == 0 || _zoomFactor == 0) + return _destRect; + + var scaledWidth = SourceWidth * _zoomFactor; + var scaledHeight = SourceHeight * _zoomFactor; + + var controlW = DrawingArea.Width; + var controlH = DrawingArea.Height; + + float virtualX, virtualY; + + if (scaledWidth <= controlW) + { + virtualX = (controlW - scaledWidth) / 2.0f + DrawingArea.Left; + } + else + { + virtualX = DrawingArea.Left - (_srcRect.X * _zoomFactor); + } + + if (scaledHeight <= controlH) + { + virtualY = (controlH - scaledHeight) / 2.0f + DrawingArea.Top; + } + else + { + virtualY = DrawingArea.Top - (_srcRect.Y * _zoomFactor); + } + + return new RectangleF(virtualX, virtualY, scaledWidth, scaledHeight); + } + + /// + /// Draws the comparison overlay. Skipped when WebView2 handles comparison (SVG). + /// + protected virtual void DrawComparisonLayer(DXGraphics g) + { + if (!_comparisonMode || IsWeb2ComparisonModeActive) return; + + var sliderX = GetSliderScreenX(); + + if (_d2dImage != null && Source != ImageSource.Null) + { + var leftClipRect = new D2D_RECT_F(0, 0, sliderX, Height); + Device.PushAxisAlignedClip(leftClipRect, D2D1_ANTIALIAS_MODE.D2D1_ANTIALIAS_MODE_ALIASED); + g.DrawBitmap(_d2dImage, _destRect, _srcRect, (D2Phap.DXControl.InterpolationMode)CurrentInterpolation); + Device.PopAxisAlignedClip(); + } + + if (_d2dCompareImage != null) + { + var rightClipRect = new D2D_RECT_F(sliderX, 0, Width, Height); + Device.PushAxisAlignedClip(rightClipRect, D2D1_ANTIALIAS_MODE.D2D1_ANTIALIAS_MODE_ALIASED); + + var compareSrcRect = CalculateCompareImageSrcRect(); + var compareDestRect = CalculateCompareImageDestRect(compareSrcRect); + + g.DrawBitmap(_d2dCompareImage, compareDestRect, compareSrcRect, (D2Phap.DXControl.InterpolationMode)CurrentInterpolation); + Device.PopAxisAlignedClip(); + } + else + { + var rightRect = new RectangleF(sliderX, 0, Width - sliderX, Height); + DrawComparisonPlaceholder(g, rightRect); + } + + var sliderXClamped = GetSliderScreenXClamped(); + DrawComparisonSlider(g, sliderX, sliderXClamped); + + DrawComparisonDropHighlight(g, sliderX); + } + + /// + /// Draws the comparison slider handle and line. + /// + private void DrawComparisonSlider(DXGraphics g, int sliderX, int handleX) + { + // DPI-scaled values + var lineWidth = DpiApi.Scale(SLIDER_LINE_WIDTH_BASE); + var handleSize = DpiApi.Scale(SLIDER_HANDLE_SIZE_BASE); + var handlePadding = (int)DpiApi.Scale(20f); + + // Draw the separator line + g.DrawLine(sliderX, 0, sliderX, Height, _accentColor, lineWidth); + + var handleY = (int)(handlePadding + (Height - 2 * handlePadding) * _comparisonSliderHandleY); + handleY = Math.Clamp(handleY, handlePadding, Height - handlePadding); + + var handleRect = new RectangleF( + handleX - handleSize / 2, + handleY - handleSize / 2, + handleSize, + handleSize); + + if (_wicCompareSliderHandle != null) + { + _d2dCompareSliderHandle ??= DXHelper.ToD2D1Bitmap(Device, _wicCompareSliderHandle); + + if (_d2dCompareSliderHandle != null) + { + var srcRect = new RectangleF(0, 0, _wicCompareSliderHandle.Width, _wicCompareSliderHandle.Height); + g.DrawBitmap(_d2dCompareSliderHandle, handleRect, srcRect, D2Phap.DXControl.InterpolationMode.Linear); + return; + } + } + + // Draw circle handle similar to crop tool resizers + var borderWidth = DpiApi.Scale(2f); + g.DrawEllipse(handleRect, Color.White.WithAlpha(50), Color.Black.WithAlpha(200), DpiApi.Scale(8f)); + g.DrawEllipse(handleRect, _accentColor, _accentColor, borderWidth); + + // Draw arrows inside handle + var arrowColor = Color.White; + var arrowOffset = DpiApi.Scale(6f); + var arrowTip = DpiApi.Scale(2f); + var arrowHeight = DpiApi.Scale(4f); + var arrowStroke = DpiApi.Scale(2f); + + // Left arrow + g.DrawLine(handleX - arrowOffset, handleY, handleX - arrowTip, handleY - arrowHeight, arrowColor, arrowStroke); + g.DrawLine(handleX - arrowOffset, handleY, handleX - arrowTip, handleY + arrowHeight, arrowColor, arrowStroke); + + // Right arrow + g.DrawLine(handleX + arrowOffset, handleY, handleX + arrowTip, handleY - arrowHeight, arrowColor, arrowStroke); + g.DrawLine(handleX + arrowOffset, handleY, handleX + arrowTip, handleY + arrowHeight, arrowColor, arrowStroke); + } + + /// + /// Draws a placeholder for empty comparison pane. + /// + private void DrawComparisonPlaceholder(DXGraphics g, RectangleF rect) + { + g.DrawRectangle(rect, 0, Color.Transparent, Color.Black.WithAlpha(30)); + } + + /// + /// Draws the drop highlight overlay during drag operations. + /// + private void DrawComparisonDropHighlight(DXGraphics g, int sliderX) + { + if (_dropHighlightPane == ComparisonPaneHover.None) return; + + RectangleF highlightRect; + if (_dropHighlightPane == ComparisonPaneHover.LeftPane) + { + highlightRect = new RectangleF(0, 0, sliderX, Height); + } + else if (_dropHighlightPane == ComparisonPaneHover.RightPane) + { + highlightRect = new RectangleF(sliderX, 0, Width - sliderX, Height); + } + else + { + return; + } + + g.DrawRectangle(highlightRect, 0, Color.Transparent, _accentColor.WithAlpha(40)); + + var borderInset = 4f; + var borderRect = new RectangleF( + highlightRect.X + borderInset, + highlightRect.Y + borderInset, + highlightRect.Width - borderInset * 2, + highlightRect.Height - borderInset * 2); + g.DrawRectangle(borderRect, 8, _accentColor.WithAlpha(180), Color.Transparent); + + var text = _dropHighlightPane == ComparisonPaneHover.LeftPane + ? DropToReplaceMainImageText + : DropToSetComparisonImageText; + var textSize = g.MeasureText(text, Font.Name, Font.Size + 2, textDpi: DeviceDpi); + var textX = highlightRect.X + (highlightRect.Width - textSize.Width) / 2; + var textY = highlightRect.Y + (highlightRect.Height - textSize.Height) / 2; + + g.DrawText(text, Font.Name, Font.Size + 2, textX, textY, Color.White, textDpi: DeviceDpi); + } + + /// + /// Calculates source rectangle for comparison image using proportional view. + /// + private RectangleF CalculateCompareImageSrcRect() + { + if (_compareImageWidth == 0 || _compareImageHeight == 0 || SourceWidth == 0 || SourceHeight == 0) + return new RectangleF(0, 0, _compareImageWidth, _compareImageHeight); + + var propX = _srcRect.X / SourceWidth; + var propY = _srcRect.Y / SourceHeight; + var propW = _srcRect.Width / SourceWidth; + var propH = _srcRect.Height / SourceHeight; + + return new RectangleF( + propX * _compareImageWidth, + propY * _compareImageHeight, + propW * _compareImageWidth, + propH * _compareImageHeight + ); + } + + /// + /// Calculates destination rectangle for comparison image. + /// + private RectangleF CalculateCompareImageDestRect(RectangleF compareSrcRect) + { + if (compareSrcRect.Width == 0 || compareSrcRect.Height == 0 || _srcRect.Width == 0) + return _destRect; + + var scale = _destRect.Width / _srcRect.Width; + var destWidth = compareSrcRect.Width * scale; + var destHeight = compareSrcRect.Height * scale; + + var mainCenterX = _destRect.X + _destRect.Width / 2; + var mainCenterY = _destRect.Y + _destRect.Height / 2; + + return new RectangleF( + mainCenterX - destWidth / 2, + mainCenterY - destHeight / 2, + destWidth, + destHeight + ); + } + + /// + /// Handles mouse down for comparison mode. + /// + private bool HandleComparisonMouseDown(MouseEventArgs e) + { + if (!_comparisonMode) return false; + + var pane = GetComparisonPaneAt(e.Location); + + if (pane == ComparisonPaneHover.Slider && e.Button == MouseButtons.Left) + { + _isDraggingComparisonSlider = true; + Cursor = Cursors.SizeWE; + return true; + } + + return false; + } + + /// + /// Handles mouse move for comparison mode. + /// + private bool HandleComparisonMouseMove(MouseEventArgs e) + { + if (!_comparisonMode) return false; + + if (_isDraggingComparisonSlider) + { + ComparisonSliderPosition = ScreenXToSliderPosition(e.X); + ComparisonSliderHandleY = ScreenYToHandlePosition(e.Y); + return true; + } + + var pane = GetComparisonPaneAt(e.Location); + _comparisonPaneHover = pane; + + if (pane == ComparisonPaneHover.Slider) + { + Cursor = Cursors.SizeAll; + return true; + } + + return false; + } + + /// + /// Converts screen Y coordinate to handle position (0-1). + /// + private float ScreenYToHandlePosition(float screenY) + { + var handlePadding = DpiApi.Scale(20f); + var usableHeight = Height - 2 * handlePadding; + if (usableHeight <= 0) return 0.5f; + + var pos = (screenY - handlePadding) / usableHeight; + return Math.Clamp(pos, 0f, 1f); + } + + /// + /// Handles mouse up for comparison mode. + /// + private bool HandleComparisonMouseUp(MouseEventArgs e) + { + if (!_comparisonMode) return false; + + if (_isDraggingComparisonSlider) + { + _isDraggingComparisonSlider = false; + Cursor = Cursors.Default; + return true; + } + + const int DRAG_THRESHOLD = 5; + var wasDrag = _mouseDownPoint.HasValue && + (Math.Abs(e.X - _mouseDownPoint.Value.X) > DRAG_THRESHOLD || + Math.Abs(e.Y - _mouseDownPoint.Value.Y) > DRAG_THRESHOLD); + + if (wasDrag) return false; + + var pane = GetComparisonPaneAt(e.Location); + if (pane == ComparisonPaneHover.LeftPane || pane == ComparisonPaneHover.RightPane) + { + ComparisonSliderPosition = ScreenXToSliderPosition(e.X); + ComparisonPaneClicked?.Invoke(this, new ComparisonPaneClickedEventArgs(pane, e)); + return true; + } + + return false; + } + + /// + /// Handles drag enter for comparison mode. + /// + private ComparisonPaneHover HandleComparisonDragOver(DragEventArgs e) + { + if (!_comparisonMode) return ComparisonPaneHover.None; + + var clientPoint = PointToClient(new Point(e.X, e.Y)); + return GetComparisonPaneAt(clientPoint); + } + + /// + /// Handles drag drop for comparison mode. + /// + private bool HandleComparisonDragDrop(DragEventArgs e) + { + if (!_comparisonMode) return false; + + var clientPoint = PointToClient(new Point(e.X, e.Y)); + var pane = GetComparisonPaneAt(clientPoint); + + if (pane == ComparisonPaneHover.LeftPane || pane == ComparisonPaneHover.RightPane) + { + if (e.Data?.GetDataPresent(DataFormats.FileDrop) == true) + { + if (e.Data.GetData(DataFormats.FileDrop) is string[] paths && paths.Length > 0) + { + ComparisonPaneFileDrop?.Invoke(this, new ComparisonPaneDropEventArgs(pane, paths[0])); + return true; + } + } + } + + return false; + } + + #endregion +} + + +/// +/// Specifies which pane is being hovered in comparison mode. +/// +public enum ComparisonPaneHover +{ + None, + LeftPane, + RightPane, + Slider, +} + + +/// +/// Event args for comparison pane click events. +/// +public class ComparisonPaneClickedEventArgs : EventArgs +{ + public ComparisonPaneHover Pane { get; } + public MouseEventArgs MouseEventArgs { get; } + + public ComparisonPaneClickedEventArgs(ComparisonPaneHover pane, MouseEventArgs mouseArgs) + { + Pane = pane; + MouseEventArgs = mouseArgs; + } +} + + +/// +/// Event args for comparison pane file drop events. +/// +public class ComparisonPaneDropEventArgs : EventArgs +{ + public ComparisonPaneHover Pane { get; } + public string FilePath { get; } + + public ComparisonPaneDropEventArgs(ComparisonPaneHover pane, string filePath) + { + Pane = pane; + FilePath = filePath; + } +} diff --git a/Source/Components/ImageGlass.Views/ViewerCanvas_Webview2.cs b/Source/Components/ImageGlass.Views/ViewerCanvas_Webview2.cs index 88729bbd1..e71ca1340 100644 --- a/Source/Components/ImageGlass.Views/ViewerCanvas_Webview2.cs +++ b/Source/Components/ImageGlass.Views/ViewerCanvas_Webview2.cs @@ -34,8 +34,11 @@ public partial class ViewerCanvas private bool _web2DarkMode = true; private string _web2NavLeftImagePath = string.Empty; private string _web2NavRightImagePath = string.Empty; + private string _web2CompareSliderHandlePath = string.Empty; private MouseEventArgs? _web2PointerDownEventArgs = null; private RectangleF _web2DestRect = RectangleF.Empty; + private bool _isWeb2ComparisonModeActive = false; + private Keys _web2LastMouseWheelModifiers = Keys.None; // Properties @@ -60,6 +63,18 @@ public partial class ViewerCanvas public bool UseWebview2 => _imageSource == ImageSource.Webview2; + /// + /// Gets whether WebView2 is handling comparison rendering (for SVG). + /// + public bool IsWeb2ComparisonModeActive => _isWeb2ComparisonModeActive; + + + /// + /// Gets modifier keys from the last WebView2 mouse wheel event. + /// + public Keys Web2LastMouseWheelModifiers => _web2LastMouseWheelModifiers; + + /// /// Gets, sets value of dark mode of . /// @@ -107,6 +122,13 @@ public string Web2NavRightImagePath } } + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public string Web2CompareSliderHandlePath + { + get => _web2CompareSliderHandlePath; + set => _web2CompareSliderHandlePath = value; + } + #endregion // Properties @@ -209,6 +231,7 @@ private void Web2_Web2MessageReceived(object? sender, Web2MessageReceivedEventAr else if (e.Name == Web2FrontendMsgNames.ON_MOUSE_WHEEL) { var mouseWheelEventArgs = ViewerCanvas.ParseMouseEventJson(e.Data); + _web2LastMouseWheelModifiers = ViewerCanvas.ParseModifierKeysFromJson(e.Data); this.OnMouseWheel(mouseWheelEventArgs); } else if (e.Name == Web2FrontendMsgNames.ON_CONTENT_SIZE_CHANGED) @@ -247,6 +270,39 @@ private void Web2_Web2MessageReceived(object? sender, Web2MessageReceivedEventAr } } } + // Handle WebView2 comparison slider changes + else if (e.Name == Web2FrontendMsgNames.ON_COMPARISON_SLIDER_CHANGED) + { + var dict = BHelper.ParseJson(e.Data) + .ToDictionary(i => i.Key, i => i.Value?.ToString() ?? string.Empty); + + if (dict.TryGetValue("SliderPosition", out var posStr) + && float.TryParse(posStr, out var pos)) + { + // Update D2D slider position to stay in sync (without triggering another event) + _comparisonSliderPos = Math.Clamp(pos, 0f, 1f); + Invalidate(); + } + } + // Handle WebView2 comparison pane file drops + else if (e.Name == Web2FrontendMsgNames.ON_COMPARISON_PANE_DROP) + { + var dict = BHelper.ParseJson(e.Data) + .ToDictionary(i => i.Key, i => i.Value?.ToString() ?? string.Empty); + + var filePaths = e.AdditionalObjects.Where(i => i is CoreWebView2File) + .Select(i => (i as CoreWebView2File).Path) + .ToArray(); + + if (dict.TryGetValue("Pane", out var paneStr) && filePaths.Length > 0) + { + var pane = paneStr.Equals("left", StringComparison.OrdinalIgnoreCase) + ? ComparisonPaneHover.LeftPane + : ComparisonPaneHover.RightPane; + + ComparisonPaneFileDrop?.Invoke(this, new ComparisonPaneDropEventArgs(pane, filePaths[0])); + } + } } #endregion // Web2 events @@ -566,6 +622,36 @@ private static MouseEventArgs ParseMouseEventJson(string json) } + /// + /// Parses modifier keys from JSON string. + /// + private static Keys ParseModifierKeysFromJson(string json) + { + var dict = BHelper.ParseJson(json) + .ToDictionary(i => i.Key, i => i.Value?.ToString() ?? string.Empty); + + var modifiers = Keys.None; + + if (dict.TryGetValue("AltKey", out var altStr) && + bool.TryParse(altStr, out var altKey) && altKey) + { + modifiers |= Keys.Alt; + } + if (dict.TryGetValue("CtrlKey", out var ctrlStr) && + bool.TryParse(ctrlStr, out var ctrlKey) && ctrlKey) + { + modifiers |= Keys.Control; + } + if (dict.TryGetValue("ShiftKey", out var shiftStr) && + bool.TryParse(shiftStr, out var shiftKey) && shiftKey) + { + modifiers |= Keys.Shift; + } + + return modifiers; + } + + /// /// Sets zoom factor for . /// @@ -703,8 +789,231 @@ private void SetWeb2NavButtonStyles() Web2.PostWeb2Message(Web2BackendMsgNames.SET_NAVIGATION, BHelper.ToJson(obj)); } - #endregion // Private methods + // WebView2 Comparison mode methods + #region WebView2 Comparison mode methods + + /// + /// Enables or disables WebView2 comparison mode. + /// + public async Task SetWeb2ComparisonModeAsync(bool enabled, CancellationToken token = default) + { + // Track the WebView2 comparison mode state + _isWeb2ComparisonModeActive = enabled; + + try + { + // If disabling and WebView2 not needed, skip initialization + if (!enabled && !IsWeb2Ready) + { + return; + } + + if (!IsWeb2Ready) + { + await InitializeWeb2Async(); + } + + // Wait for Web2 navigation to complete before sending messages + while (!_isWeb2NavigationDone) + { + await Task.Delay(10, token); + } + + token.ThrowIfCancellationRequested(); + + // Ensure Web2 is still valid after waiting + if (Web2 == null) return; + + // Bring WebView2 to front when enabling comparison mode + // so it renders on top of D2D canvas + if (enabled) + { + Web2.BringToFront(); + Web2.Visible = true; + } + else if (!UseWebview2) + { + // Hide WebView2 when disabling comparison and not using WebView2 for main image + Web2.SendToBack(); + await Web2.SetWeb2VisibilityAsync(false); + } + + var sliderHandleUrl = string.Empty; + if (!string.IsNullOrWhiteSpace(Web2CompareSliderHandlePath)) + { + sliderHandleUrl = new Uri(Web2CompareSliderHandlePath).AbsoluteUri; + } + + var obj = new ExpandoObject(); + _ = obj.TryAdd("Enabled", enabled); + _ = obj.TryAdd("AccentColor", AccentColor.ToRgbaArray().ToList()); + _ = obj.TryAdd("SliderHandleUrl", sliderHandleUrl); + + Web2.PostWeb2Message(Web2BackendMsgNames.SET_COMPARISON_MODE, BHelper.ToJson(obj)); + } + catch (Exception ex) when (ex is OperationCanceledException or TaskCanceledException) { } + } + + /// + /// Sets the comparison images in WebView2. + /// + public async Task SetWeb2ComparisonImagesAsync( + string? leftImagePath, + string? leftImageHtml, + string? rightImagePath, + string? rightImageHtml, + CancellationToken token = default) + { + // Store comparison image path for cycling to work correctly + _compareImagePath = rightImagePath ?? string.Empty; + + try + { + if (!IsWeb2Ready) + { + await InitializeWeb2Async(); + } + + // Wait for Web2 navigation to complete before sending messages + while (!_isWeb2NavigationDone) + { + await Task.Delay(10, token); + } + + token.ThrowIfCancellationRequested(); + + // Ensure Web2 is still valid after waiting + if (Web2 == null) return; + + var obj = new ExpandoObject(); + + // Left image + if (!string.IsNullOrEmpty(leftImageHtml)) + { + _ = obj.TryAdd("LeftImageHtml", leftImageHtml); + _ = obj.TryAdd("LeftImageUrl", string.Empty); + } + else if (!string.IsNullOrEmpty(leftImagePath)) + { + _ = obj.TryAdd("LeftImageUrl", new Uri(leftImagePath).AbsoluteUri); + _ = obj.TryAdd("LeftImageHtml", string.Empty); + } + + // Right image + if (!string.IsNullOrEmpty(rightImageHtml)) + { + _ = obj.TryAdd("RightImageHtml", rightImageHtml); + _ = obj.TryAdd("RightImageUrl", string.Empty); + } + else if (!string.IsNullOrEmpty(rightImagePath)) + { + _ = obj.TryAdd("RightImageUrl", new Uri(rightImagePath).AbsoluteUri); + _ = obj.TryAdd("RightImageHtml", string.Empty); + } + + Web2.PostWeb2Message(Web2BackendMsgNames.SET_COMPARISON_IMAGES, BHelper.ToJson(obj)); + } + catch (Exception ex) when (ex is OperationCanceledException or TaskCanceledException) { } + } + + /// + /// Sets the comparison slider position in WebView2. + /// + public void SetWeb2ComparisonSlider(float position) + { + if (!IsWeb2Ready) return; + + var obj = new ExpandoObject(); + _ = obj.TryAdd("Position", Math.Clamp(position, 0f, 1f)); + + Web2.PostWeb2Message(Web2BackendMsgNames.SET_COMPARISON_SLIDER, BHelper.ToJson(obj)); + } + + /// + /// Reads SVG file content for use in WebView2 comparison. + /// + public static async Task ReadSvgContentAsync(string filePath, CancellationToken token = default) + { + if (string.IsNullOrEmpty(filePath)) return null; + if (!filePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) return null; + if (!File.Exists(filePath)) return null; + + try + { + return await File.ReadAllTextAsync(filePath, token); + } + catch + { + return null; + } + } + + + /// + /// Reads image file content as HTML for WebView2 comparison. + /// Returns SVG content for SVG files, null for animated formats (use URL instead), + /// or base64 img tag for other raster images. + /// + public static async Task ReadImageAsHtmlAsync(string filePath, CancellationToken token = default) + { + if (string.IsNullOrEmpty(filePath)) return null; + if (!File.Exists(filePath)) return null; + + try + { + // For SVG files, return the SVG content directly + if (filePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) + { + return await File.ReadAllTextAsync(filePath, token); + } + + // For animated formats, return null so the file URL is used instead + // This ensures animation works properly without base64 overhead + if (Const.ANIMATED_FORMATS.Any(ext => filePath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) + { + return null; + } + + // For other raster images, convert to base64 img tag + var bytes = await File.ReadAllBytesAsync(filePath, token); + var base64 = Convert.ToBase64String(bytes); + var mimeType = GetMimeType(filePath); + + return $""; + } + catch + { + return null; + } + } + + + /// + /// Gets the MIME type for an image file. + /// + private static string GetMimeType(string filePath) + { + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + return ext switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" or ".jpe" or ".jfif" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".bmp" or ".dib" => "image/bmp", + ".ico" => "image/x-icon", + ".tif" or ".tiff" => "image/tiff", + ".avif" => "image/avif", + ".heic" or ".heif" => "image/heic", + ".jxl" => "image/jxl", + _ => "image/png", // fallback + }; + } + + #endregion // WebView2 Comparison mode methods + + } diff --git a/Source/ImageGlass/FrmMain.Designer.cs b/Source/ImageGlass/FrmMain.Designer.cs index fb28b21a0..91edc292c 100644 --- a/Source/ImageGlass/FrmMain.Designer.cs +++ b/Source/ImageGlass/FrmMain.Designer.cs @@ -140,6 +140,7 @@ private void InitializeComponent() MnuTools = new ToolStripMenuItem(); MnuColorPicker = new ToolStripMenuItem(); MnuCropTool = new ToolStripMenuItem(); + MnuCompareTool = new ToolStripMenuItem(); MnuResizeTool = new ToolStripMenuItem(); MnuFrameNav = new ToolStripMenuItem(); MnuLosslessCompression = new ToolStripMenuItem(); @@ -911,7 +912,7 @@ private void InitializeComponent() // // MnuLayout // - MnuLayout.DropDownItems.AddRange(new ToolStripItem[] { MnuToggleToolbar, MnuToggleGallery, MnuToggleCheckerboard, toolStripMenuItem20, MnuToggleTopMost, MnuChangeBackgroundColor }); + MnuLayout.DropDownItems.AddRange(new ToolStripItem[] { MnuToggleToolbar, MnuToggleGallery, MnuToggleCheckerboard, MnuCompareTool, toolStripMenuItem20, MnuToggleTopMost, MnuChangeBackgroundColor }); MnuLayout.Image = (Image)resources.GetObject("MnuLayout.Image"); MnuLayout.ImageAlign = ContentAlignment.MiddleLeft; MnuLayout.ImageScaling = ToolStripItemImageScaling.None; @@ -988,7 +989,15 @@ private void InitializeComponent() MnuCropTool.Size = new Size(195, 22); MnuCropTool.Text = "[Crop image]"; MnuCropTool.Click += MnuCropTool_Click; - // + // + // MnuCompareTool + // + MnuCompareTool.CheckOnClick = true; + MnuCompareTool.Name = "MnuCompareTool"; + MnuCompareTool.Size = new Size(195, 22); + MnuCompareTool.Text = "[Compare images]"; + MnuCompareTool.Click += MnuCompareTool_Click; + // // MnuResizeTool // MnuResizeTool.Name = "MnuResizeTool"; @@ -1135,6 +1144,7 @@ private void InitializeComponent() PicMain.Web2KeyUp += PicMain_Web2KeyUp; PicMain.DragDrop += PicMain_DragDrop; PicMain.DragEnter += PicMain_DragEnter; + PicMain.DragLeave += PicMain_DragLeave; PicMain.DragOver += PicMain_DragOver; PicMain.MouseClick += PicMain_MouseClick; PicMain.MouseDoubleClick += PicMain_MouseDoubleClick; @@ -1333,6 +1343,7 @@ private void InitializeComponent() public ToolStripMenuItem MnuToggleTopMost; public ToolStripMenuItem MnuColorPicker; public ToolStripMenuItem MnuCropTool; + public ToolStripMenuItem MnuCompareTool; public ToolStripMenuItem MnuFrameNav; public ToolStripMenuItem MnuAbout; public ToolStripMenuItem MnuCheckForUpdate; diff --git a/Source/ImageGlass/FrmMain.cs b/Source/ImageGlass/FrmMain.cs index 8a500c109..89bbce940 100644 --- a/Source/ImageGlass/FrmMain.cs +++ b/Source/ImageGlass/FrmMain.cs @@ -39,6 +39,8 @@ public partial class FrmMain : ThemedForm // cancellation tokens of synchronious task private CancellationTokenSource? _loadCancelTokenSrc = new(); + private CancellationTokenSource? _comparisonLoadCts = new(); + private CancellationTokenSource? _comparisonUpdateCts = new(); private readonly IProgress _uiReporter; private MovableForm? _movableForm; private FileFinder? _fileFinder = new(); @@ -162,6 +164,26 @@ private void FrmMain_KeyDown(object sender, KeyEventArgs e) return; } + // Comparison mode keyboard shortcuts (D2D mode only; WebView2 handles its own) + if (PicMain.ComparisonMode && !PicMain.IsWeb2ComparisonModeActive) + { + // Backspace: Reset slider to center + if (e.KeyCode == Keys.Back) + { + PicMain.ResetComparisonSlider(); + e.Handled = true; + return; + } + + // Backtick (`): Reset zoom and pan to fit the image + if (e.KeyCode == Keys.Oem3) + { + PicMain.Refresh(); + e.Handled = true; + return; + } + } + #endregion // Special actions @@ -255,7 +277,13 @@ private void Gallery_ItemClick(object sender, ItemClickEventArgs e) { if (e.Buttons == MouseButtons.Left) { - if (e.Item.Index == Local.CurrentIndex) + var ctrlPressed = ModifierKeys.HasFlag(Keys.Control); + + if (ctrlPressed && PicMain.ComparisonMode) + { + _ = LoadComparisonImageAsync(e.Item.FilePath); + } + else if (e.Item.Index == Local.CurrentIndex) { PicMain.Refresh(); } @@ -1130,6 +1158,15 @@ private async Task HandleImageProgress_LoadedAsync(ImageLoadedEventArgs e) SelectCurrentGalleryThumbnail(); } + // update comparison A indicator in gallery + if (Gallery.ComparisonMode) + { + Gallery.ComparisonMainIndex = Local.CurrentIndex; + Gallery.Refresh(true, false); + + // Update comparison when A image changes (handles D2D/WebView2 transitions) + _ = UpdateComparisonLeftImageAsync(); + } LoadImageInfo(ImageInfoUpdateTypes.Dimension | ImageInfoUpdateTypes.FrameCount); @@ -1570,7 +1607,15 @@ public void LoadImageInfo(ImageInfoUpdateTypes? types = null, string? filename = } + var originalName = ImageInfo.Name; + if (PicMain.ComparisonMode && !string.IsNullOrEmpty(PicMain.CompareImagePath)) + { + var bName = Path.GetFileName(PicMain.CompareImagePath); + ImageInfo.Name = $"Compare: {originalName} ⇄ {bName}"; + } + Text = ImageInfo.ToString(Config.ImageInfoTags, Local.ClipboardImage != null, clipboardImageText); + ImageInfo.Name = originalName; } @@ -2395,6 +2440,11 @@ private void MnuCropTool_Click(object sender, EventArgs e) IG_ToggleCropTool(); } + private void MnuCompareTool_Click(object sender, EventArgs e) + { + IG_ToggleCompareTool(); + } + private void MnuResizeTool_Click(object sender, EventArgs e) { IG_OpenResizeTool(); diff --git a/Source/ImageGlass/FrmMain/FrmMain.Configs.cs b/Source/ImageGlass/FrmMain/FrmMain.Configs.cs index 0e6c00758..10c74567f 100644 --- a/Source/ImageGlass/FrmMain/FrmMain.Configs.cs +++ b/Source/ImageGlass/FrmMain/FrmMain.Configs.cs @@ -129,6 +129,7 @@ public partial class FrmMain // MnuTools { nameof(MnuColorPicker), [new(Keys.K)] }, { nameof(MnuCropTool), [new(Keys.C)] }, + { nameof(MnuCompareTool), [new(Keys.Alt | Keys.M)] }, { nameof(MnuFrameNav), [new(Keys.P)] }, { nameof(MnuResizeTool), [new(Keys.Alt | Keys.R)] }, { nameof(MnuLosslessCompression), [new(Keys.Alt | Keys.C)] }, @@ -180,6 +181,14 @@ private void SetUpFrmMainConfigs() PicMain.ZoomFactor = Config.ZoomLockValue / 100f; } + // comparison mode events + PicMain.ComparisonPaneClicked += PicMain_ComparisonPaneClicked; + PicMain.ComparisonPaneFileDrop += PicMain_ComparisonPaneFileDrop; + + // comparison mode localized strings + PicMain.DropToReplaceMainImageText = Config.Language["FrmCompare._DropToReplaceMainImage"]; + PicMain.DropToSetComparisonImageText = Config.Language["FrmCompare._DropToSetComparisonImage"]; + IG_ToggleCheckerboard(Config.ShowCheckerboard); // set up layout @@ -775,6 +784,7 @@ public void LoadLanguage() MnuToggleToolbar.Text = lang[$"{Name}.{nameof(MnuToggleToolbar)}"]; MnuToggleGallery.Text = lang[$"{Name}.{nameof(MnuToggleGallery)}"]; MnuToggleCheckerboard.Text = lang[$"{Name}.{nameof(MnuToggleCheckerboard)}"]; + MnuCompareTool.Text = lang[$"{Name}.{nameof(MnuCompareTool)}"]; MnuToggleTopMost.Text = lang[$"{Name}.{nameof(MnuToggleTopMost)}"]; MnuChangeBackgroundColor.Text = lang[$"{Name}.{nameof(MnuChangeBackgroundColor)}"]; #endregion diff --git a/Source/ImageGlass/FrmMain/FrmMain.IGMethods.cs b/Source/ImageGlass/FrmMain/FrmMain.IGMethods.cs index 52a6a771b..ce42c307a 100644 --- a/Source/ImageGlass/FrmMain/FrmMain.IGMethods.cs +++ b/Source/ImageGlass/FrmMain/FrmMain.IGMethods.cs @@ -215,6 +215,13 @@ public void IG_GoTo() public void GoToImage(int index) { Local.CurrentIndex = index; + + if (PicMain.ComparisonMode) + { + Gallery.ComparisonMainIndex = index; + Gallery.Refresh(true, false); + } + _ = ViewNextCancellableAsync(0); } @@ -2910,6 +2917,16 @@ public void IG_FlipImage(FlipOptions options) { if (PicMain.Source == ImageSource.Null || Local.IsBusy) return; + // Disable during comparison mode + if (PicMain.ComparisonMode) + { + PicMain.ShowMessage( + text: Config.Language["_._InvalidAction._ComparisonMode"], + heading: Config.Language["_._InvalidAction"], + durationMs: Config.InAppMessageDuration); + return; + } + // update flip changes if (PicMain.FlipImage(options)) { @@ -2955,6 +2972,16 @@ public void IG_Rotate(RotateOption option) { if (PicMain.Source == ImageSource.Null || Local.IsBusy) return; + // Disable during comparison mode + if (PicMain.ComparisonMode) + { + PicMain.ShowMessage( + text: Config.Language["_._InvalidAction._ComparisonMode"], + heading: Config.Language["_._InvalidAction"], + durationMs: Config.InAppMessageDuration); + return; + } + var degree = option == RotateOption.Left ? -90 : 90; // update rotation changes @@ -2985,6 +3012,16 @@ public void IG_InvertColors() { if (PicMain.Source == ImageSource.Null || Local.IsBusy) return; + // Disable during comparison mode + if (PicMain.ComparisonMode) + { + PicMain.ShowMessage( + text: Config.Language["_._InvalidAction._ComparisonMode"], + heading: Config.Language["_._InvalidAction"], + durationMs: Config.InAppMessageDuration); + return; + } + // invert image colors if (PicMain.InvertColor(true)) { @@ -3125,6 +3162,16 @@ private void ToolForm_ToolFormClosing(ToolFormClosingEventArgs e) /// public bool IG_ToggleCropTool(bool? visible = null) { + // Disable crop tool during comparison mode + if (PicMain.ComparisonMode && visible != false) + { + PicMain.ShowMessage( + text: Config.Language["_._InvalidAction._ComparisonMode"], + heading: Config.Language["_._InvalidAction"], + durationMs: Config.InAppMessageDuration); + return false; + } + visible ??= MnuCropTool.Checked; // update menu item state @@ -3180,6 +3227,172 @@ public bool IG_ToggleColorPicker(bool? visible = null) } + /// + /// Toggles Compare tool. + /// + public bool IG_ToggleCompareTool(bool? visible = null) + { + visible ??= !PicMain.ComparisonMode; + + MnuCompareTool.Checked = visible.Value; + UpdateToolbarItemsState(); + + PicMain.ComparisonMode = visible.Value; + + Gallery.ComparisonMode = visible.Value; + if (visible.Value) + { + Gallery.ComparisonMainIndex = Local.CurrentIndex; + + if (ShouldUseWeb2ForComparison()) + { + _ = EnableWeb2ComparisonModeAsync(); + } + else + { + _ = PicMain.SetWeb2ComparisonModeAsync(false); + } + + PicMain.ShowMessage( + Config.Language[$"FrmCompare._SelectImageToCompare"], + heading: Config.Language[$"{Name}.{nameof(MnuCompareTool)}"], + durationMs: Config.InAppMessageDuration); + } + else + { + Gallery.ComparisonMainIndex = -1; + Gallery.ComparisonImagePath = string.Empty; + PicMain.ClearCompareImage(); + + _ = PicMain.SetWeb2ComparisonModeAsync(false); + } + Gallery.Refresh(true, false); + LoadImageInfo(); + + return visible.Value; + } + + /// + /// Checks if WebView2 should be used for comparison mode. + /// Returns true if either image is SVG (and WebView2 is enabled for SVG), + /// or if either image is an animated format (GIF, WEBP, etc.). + /// + private bool ShouldUseWeb2ForComparison(string? rightImagePath = null) + { + var leftImagePath = Local.Images.GetFilePath(Local.CurrentIndex); + + // Check for animated formats - always use WebView2 for animation support + var leftIsAnimated = Const.ANIMATED_FORMATS.Any(ext => + leftImagePath?.EndsWith(ext, StringComparison.OrdinalIgnoreCase) == true); + var rightIsAnimated = Const.ANIMATED_FORMATS.Any(ext => + rightImagePath?.EndsWith(ext, StringComparison.OrdinalIgnoreCase) == true); + + if (leftIsAnimated || rightIsAnimated) return true; + + // Check for SVG (requires UseWebview2ForSvg setting) + if (!Config.UseWebview2ForSvg) return false; + + var leftIsSvg = leftImagePath?.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) == true; + var rightIsSvg = rightImagePath?.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) == true; + + return leftIsSvg || rightIsSvg; + } + + /// + /// Enables WebView2 comparison mode and loads the current image. + /// + private async Task EnableWeb2ComparisonModeAsync() + { + await PicMain.SetWeb2ComparisonModeAsync(true); + + // Load current image as left (A) image (supports SVG or raster) + var leftImagePath = Local.Images.GetFilePath(Local.CurrentIndex); + if (!string.IsNullOrEmpty(leftImagePath)) + { + var leftHtml = await ViewerCanvas.ReadImageAsHtmlAsync(leftImagePath); + await PicMain.SetWeb2ComparisonImagesAsync( + leftImagePath, + leftHtml, + null, + null); + } + } + + + /// + /// Updates the left (A) comparison image, handling D2D/WebView2 transitions. + /// + private async Task UpdateComparisonLeftImageAsync() + { + _comparisonUpdateCts?.Cancel(); + _comparisonUpdateCts?.Dispose(); + _comparisonUpdateCts = new CancellationTokenSource(); + var token = _comparisonUpdateCts.Token; + + try + { + var rightImagePath = Gallery.ComparisonImagePath; + var leftImagePath = Local.Images.GetFilePath(Local.CurrentIndex); + if (string.IsNullOrEmpty(leftImagePath)) return; + + var needsWebView2 = ShouldUseWeb2ForComparison(rightImagePath); + + if (needsWebView2) + { + if (!PicMain.IsWeb2ComparisonModeActive) + { + await PicMain.SetWeb2ComparisonModeAsync(true, token); + } + token.ThrowIfCancellationRequested(); + + var leftHtml = await ViewerCanvas.ReadImageAsHtmlAsync(leftImagePath, token); + token.ThrowIfCancellationRequested(); + + var rightHtml = !string.IsNullOrEmpty(rightImagePath) + ? await ViewerCanvas.ReadImageAsHtmlAsync(rightImagePath, token) + : null; + token.ThrowIfCancellationRequested(); + + await PicMain.SetWeb2ComparisonImagesAsync( + leftImagePath, + leftHtml, + rightImagePath, + rightHtml, + token); + } + else + { + var wasWeb2Active = PicMain.IsWeb2ComparisonModeActive; + if (wasWeb2Active) + { + await PicMain.SetWeb2ComparisonModeAsync(false, token); + } + token.ThrowIfCancellationRequested(); + + if (!string.IsNullOrEmpty(rightImagePath) && (!PicMain.HasCompareImage || wasWeb2Active)) + { + var imgData = await PhotoCodec.LoadAsync(rightImagePath, new CodecReadOptions() + { + ColorProfileName = Config.ColorProfile, + FirstFrameOnly = true, + }, null, token); + + token.ThrowIfCancellationRequested(); + + if (imgData != null) + { + PicMain.SetCompareImage(imgData, rightImagePath); + } + } + } + } + catch (OperationCanceledException) + { + // Expected when navigating rapidly + } + } + + /// /// Toggles Frame navigation tool. /// @@ -3490,6 +3703,15 @@ private void SetImageColorChannels(ColorChannels channels) { if (PicMain.Source == ImageSource.Null || Local.IsBusy) return; + // Disable during comparison mode + if (PicMain.ComparisonMode) + { + PicMain.ShowMessage( + text: Config.Language["_._InvalidAction._ComparisonMode"], + heading: Config.Language["_._InvalidAction"], + durationMs: Config.InAppMessageDuration); + return; + } // apply color channels filter if (PicMain.FilterColorChannels(channels, false)) diff --git a/Source/ImageGlass/FrmMain/FrmMain.PicMainEvents.cs b/Source/ImageGlass/FrmMain/FrmMain.PicMainEvents.cs index 381c53bae..987a38995 100644 --- a/Source/ImageGlass/FrmMain/FrmMain.PicMainEvents.cs +++ b/Source/ImageGlass/FrmMain/FrmMain.PicMainEvents.cs @@ -20,6 +20,7 @@ You should have received a copy of the GNU General Public License using Cysharp.Text; using D2Phap; using ImageGlass.Base; +using ImageGlass.Base.Photoing.Codecs; using ImageGlass.Settings; using ImageGlass.Viewer; @@ -40,6 +41,15 @@ private void PicMain_DragEnter(object sender, DragEventArgs e) } + private void PicMain_DragLeave(object? sender, EventArgs e) + { + if (PicMain.ComparisonMode) + { + PicMain.ClearComparisonDropHighlight(); + } + } + + private void PicMain_DragOver(object? sender, DragEventArgs e) { try @@ -56,6 +66,12 @@ private void PicMain_DragOver(object? sender, DragEventArgs e) if (data is not string[] paths) return; var filePath = paths[0]; + if (PicMain.ComparisonMode && paths.Length == 1) + { + var targetPane = PicMain.GetDropTargetPane(new Point(e.X, e.Y)); + PicMain.SetComparisonDropHighlight(targetPane); + } + // KBR 20190617 Fix observed issue: dragging from CD/DVD would fail because // we set the drag effect to Move, which is not allowed // Drag file from DESKTOP to APP @@ -88,6 +104,25 @@ private async Task HandlePicMainDragDropAsync(DragEventArgs e) if (e.Data is null || !e.Data.GetDataPresent(DataFormats.FileDrop)) return; if (e.Data.GetData(DataFormats.FileDrop, false) is not string[] paths) return; + if (PicMain.ComparisonMode && paths.Length == 1) + { + var clientPoint = PicMain.PointToClient(new Point(e.X, e.Y)); + var sliderX = PicMain.ComparisonSliderScreenX; + var filePath = BHelper.ResolvePath(paths[0]); + + PicMain.ClearComparisonDropHighlight(); + + if (clientPoint.X < sliderX) + { + PrepareLoading(filePath, false); + } + else + { + await LoadComparisonImageAsync(filePath); + } + return; + } + if (paths.Length > 1) { await PrepareLoadingAsync(paths); @@ -95,8 +130,8 @@ private async Task HandlePicMainDragDropAsync(DragEventArgs e) } - var filePath = BHelper.ResolvePath(paths[0]); - var imageIndex = Local.Images.IndexOf(filePath); + var resolvedPath = BHelper.ResolvePath(paths[0]); + var imageIndex = Local.Images.IndexOf(resolvedPath); // get foreground shell @@ -107,13 +142,13 @@ private async Task HandlePicMainDragDropAsync(DragEventArgs e) } // save init input path - Program.UpdateInputImagePath(filePath); + Program.UpdateInputImagePath(resolvedPath); // The file is located another folder, load the entire folder if (imageIndex == -1 || Program.CanUseForegroundShell()) { - PrepareLoading(filePath, false); + PrepareLoading(resolvedPath, false); } // The file is in current folder AND it is the viewing image else if (Local.CurrentIndex == imageIndex) @@ -294,15 +329,25 @@ private void PicMain_MouseDoubleClick(object? sender, MouseEventArgs e) private void PicMain_MouseWheel(object? sender, MouseEventArgs e) { MouseWheelAction action; + var modifiers = PicMain.UseWebview2 ? PicMain.Web2LastMouseWheelModifiers : ModifierKeys; - var eventType = ModifierKeys switch + MouseWheelEvent eventType; + if (modifiers.HasFlag(Keys.Control)) { - Keys.Control => MouseWheelEvent.CtrlAndScroll, - Keys.Shift => MouseWheelEvent.ShiftAndScroll, - Keys.Alt => MouseWheelEvent.AltAndScroll, - _ => MouseWheelEvent.Scroll, - }; - + eventType = MouseWheelEvent.CtrlAndScroll; + } + else if (modifiers.HasFlag(Keys.Shift)) + { + eventType = MouseWheelEvent.ShiftAndScroll; + } + else if (modifiers.HasFlag(Keys.Alt)) + { + eventType = MouseWheelEvent.AltAndScroll; + } + else + { + eventType = MouseWheelEvent.Scroll; + } // Get mouse wheel action #region Get mouse wheel action @@ -368,13 +413,25 @@ private void PicMain_MouseWheel(object? sender, MouseEventArgs e) } else if (action == MouseWheelAction.BrowseImages) { - if (e.Delta < 0) + var paneAtCursor = PicMain.ComparisonMode + ? PicMain.GetComparisonPaneAt(e.Location) + : ImageGlass.Viewer.ComparisonPaneHover.None; + + if (paneAtCursor == ImageGlass.Viewer.ComparisonPaneHover.RightPane) { - IG_ViewImage(1); + var delta = e.Delta < 0 ? 1 : -1; + _ = CycleComparisonImageAsync(delta); } else { - IG_ViewImage(-1); + if (e.Delta < 0) + { + IG_ViewImage(1); + } + else + { + IG_ViewImage(-1); + } } } #endregion @@ -444,4 +501,181 @@ private void PicMain_Web2KeyUp(object sender, KeyEventArgs e) this.OnKeyUp(e); } + + // Comparison mode events + #region Comparison mode events + + private void PicMain_ComparisonPaneClicked(object? sender, ComparisonPaneClickedEventArgs e) + { + } + + + private void PicMain_ComparisonPaneFileDrop(object? sender, ComparisonPaneDropEventArgs e) + { + if (e.Pane == ComparisonPaneHover.RightPane) + { + _ = LoadComparisonImageAsync(e.FilePath); + } + else if (e.Pane == ComparisonPaneHover.LeftPane) + { + PrepareLoading(e.FilePath, false); + } + } + + + /// + /// Opens a file picker to select a comparison image. + /// + private async Task OpenComparisonImageAsync() + { + using var sb = ZString.CreateStringBuilder(); + foreach (var ext in Config.FileFormats) + { + sb.Append($"*{ext};"); + } + + using var o = new OpenFileDialog() + { + Title = Config.Language[$"FrmCompare.BtnSelectImage._DialogTitle"], + Filter = Config.Language[$"{Name}._OpenFileDialog"] + "|" + sb.ToString(), + CheckFileExists = true, + RestoreDirectory = true, + }; + + // Set initial directory based on current image + var currentPath = Local.Images.GetFilePath(Local.CurrentIndex); + if (!string.IsNullOrEmpty(currentPath)) + { + o.InitialDirectory = Path.GetDirectoryName(currentPath); + } + + if (o.ShowDialog() == DialogResult.OK) + { + await LoadComparisonImageAsync(o.FileName); + } + } + + + /// + /// Loads an image file as the comparison image. + /// + private async Task LoadComparisonImageAsync(string filePath) + { + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) return; + + _comparisonLoadCts?.Cancel(); + _comparisonLoadCts?.Dispose(); + _comparisonLoadCts = new CancellationTokenSource(); + var token = _comparisonLoadCts.Token; + + try + { + Gallery.ComparisonImagePath = filePath; + Gallery.Refresh(true, false); + + if (ShouldUseWeb2ForComparison(filePath)) + { + await PicMain.SetWeb2ComparisonModeAsync(true, token); + if (token.IsCancellationRequested) return; + + var leftImagePath = Local.Images.GetFilePath(Local.CurrentIndex); + var leftHtml = await ViewerCanvas.ReadImageAsHtmlAsync(leftImagePath, token); + if (token.IsCancellationRequested) return; + + var rightHtml = await ViewerCanvas.ReadImageAsHtmlAsync(filePath, token); + if (token.IsCancellationRequested) return; + + await PicMain.SetWeb2ComparisonImagesAsync( + leftImagePath, + leftHtml, + filePath, + rightHtml, + token); + } + else + { + var imgData = await PhotoCodec.LoadAsync(filePath, new CodecReadOptions() + { + ColorProfileName = Config.ColorProfile, + FirstFrameOnly = true, + }, null, token); + + if (token.IsCancellationRequested) return; + + if (imgData != null) + { + PicMain.SetCompareImage(imgData, filePath); + } + + await PicMain.SetWeb2ComparisonModeAsync(false, token); + } + + if (token.IsCancellationRequested) return; + + PicMain.ShowMessage( + Path.GetFileName(filePath), + heading: Config.Language[$"{Name}.{nameof(MnuCompareTool)}"], + durationMs: Config.InAppMessageDuration); + + LoadImageInfo(); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + PicMain.ShowMessage( + ex.Message, + heading: Config.Language[$"{Name}.{nameof(MnuCompareTool)}"], + durationMs: Config.InAppMessageDuration); + } + } + + + /// + /// Cycles the comparison image by the given delta (1 for next, -1 for previous). + /// + private async Task CycleComparisonImageAsync(int delta) + { + if (Local.Images.Length == 0) return; + + var currentCompareIndex = -1; + var comparePath = PicMain.CompareImagePath; + + if (!string.IsNullOrEmpty(comparePath)) + { + for (var i = 0; i < Local.Images.Length; i++) + { + if (string.Equals(Local.Images.GetFilePath(i), comparePath, StringComparison.OrdinalIgnoreCase)) + { + currentCompareIndex = i; + break; + } + } + } + + if (currentCompareIndex < 0) + { + currentCompareIndex = Local.CurrentIndex; + } + + // Calculate new index with wrapping + var newIndex = currentCompareIndex + delta; + if (newIndex >= Local.Images.Length) newIndex = 0; + if (newIndex < 0) newIndex = Local.Images.Length - 1; + + if (newIndex == Local.CurrentIndex) + { + newIndex += delta; + if (newIndex >= Local.Images.Length) newIndex = 0; + if (newIndex < 0) newIndex = Local.Images.Length - 1; + } + + var newPath = Local.Images.GetFilePath(newIndex); + if (!string.IsNullOrEmpty(newPath)) + { + await LoadComparisonImageAsync(newPath); + } + } + + #endregion // Comparison mode events + } diff --git a/Source/ImageGlass/FrmMain/FrmMain.Theme.cs b/Source/ImageGlass/FrmMain/FrmMain.Theme.cs index 31fccc30e..cc0febe01 100644 --- a/Source/ImageGlass/FrmMain/FrmMain.Theme.cs +++ b/Source/ImageGlass/FrmMain/FrmMain.Theme.cs @@ -79,10 +79,12 @@ protected override void ApplyTheme(bool darkMode, BackdropStyle? style = null) PicMain.NavButtonColor = Config.Theme.Colors.NavigationButtonColor; PicMain.NavLeftImage = Config.Theme.Settings.NavButtonLeft; PicMain.NavRightImage = Config.Theme.Settings.NavButtonRight; + PicMain.CompareSliderHandleImage = Config.Theme.Settings.CompareSliderHandle; PicMain.Web2DarkMode = darkMode; PicMain.Web2NavLeftImagePath = Config.Theme.NavLeftImagePath; PicMain.Web2NavRightImagePath = Config.Theme.NavRightImagePath; + PicMain.Web2CompareSliderHandlePath = Config.Theme.CompareSliderHandlePath; // Thumbnail bar diff --git a/Source/ImageGlass/Local.cs b/Source/ImageGlass/Local.cs index 3b4731540..7d7129093 100644 --- a/Source/ImageGlass/Local.cs +++ b/Source/ImageGlass/Local.cs @@ -80,6 +80,12 @@ public class Local Image = nameof(Config.Theme.ToolbarIcons.Crop), OnClick = new(nameof(FrmMain.MnuCropTool)), }, + new() // MnuCompareTool + { + Id = $"Btn_{nameof(FrmMain.MnuCompareTool)}", + Image = nameof(Config.Theme.ToolbarIcons.Compare), + OnClick = new(nameof(FrmMain.MnuCompareTool)), + }, new() // MnuMoveToRecycleBin { Id = $"Btn_{nameof(FrmMain.MnuMoveToRecycleBin)}", @@ -256,6 +262,7 @@ public class Local nameof(ToolbarItemModelType.Separator), $"Btn_{nameof(FrmMain.MnuRefresh)}", $"Btn_{nameof(FrmMain.MnuToggleGallery)}", + $"Btn_{nameof(FrmMain.MnuCompareTool)}", $"Btn_{nameof(FrmMain.MnuToggleCheckerboard)}", $"Btn_{nameof(FrmMain.MnuFullScreen)}", $"Btn_{nameof(FrmMain.MnuSlideshow)}",