[Phase L2-3] 클립보드 이미지 미리보기 창 구현

신규 파일:
- ClipboardImagePreviewWindow.xaml (신규, 140줄):
  WindowStyle=None, 커스텀 타이틀바, 이미지 ScaleTransform 줌,
  하단 툴바(축소/줌레이블/확대 / 1:1 / 창맞춤 / 저장 / 복사)
- ClipboardImagePreviewWindow.xaml.cs (신규, 173줄):
  원본 해상도 이미지 로드(OriginalImagePath → 썸네일 폴백),
  FitToWindow() 초기 자동 맞춤, ApplyZoom(ScaleTransform),
  Ctrl+휠 줌, 키보드 단축키(+/-/0/F/Esc/Ctrl+C/Ctrl+S),
  PNG·JPEG·BMP 저장(SaveFileDialog), NotificationCenterService 피드백

변경 파일:
- LauncherWindow.Keyboard.cs: Shift+Enter 시 ClipboardEntry 이미지 항목 → 미리보기 창 열기(기존 폴더열기 분기 전에 추가)
- LauncherWindow.ShortcutHelp.cs: "Shift+Enter — 대형 텍스트 / 이미지 미리보기 (#)" 안내 갱신

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 09:55:59 +09:00
parent cc14de8da3
commit ff048a6198
4 changed files with 467 additions and 1 deletions

View File

@@ -0,0 +1,225 @@
<Window x:Class="AxCopilot.Views.ClipboardImagePreviewWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Copilot — 이미지 미리보기"
Width="820" Height="640"
WindowStyle="None" AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="CenterScreen"
ResizeMode="CanResizeWithGrip"
MinWidth="320" MinHeight="260">
<!-- Phase L2-3: 클립보드 이미지 미리보기 창 -->
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="12"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Margin="6">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="4" Opacity="0.3" Color="Black" Direction="270"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/> <!-- 타이틀바 -->
<RowDefinition Height="*"/> <!-- 이미지 영역 -->
<RowDefinition Height="46"/> <!-- 하단 툴바 -->
</Grid.RowDefinitions>
<!-- ─── 타이틀바 ─────────────────────────────────────────────── -->
<Border Grid.Row="0" CornerRadius="12,12,0,0"
Background="{DynamicResource ItemBackground}"
MouseLeftButtonDown="TitleBar_MouseDown">
<Grid Margin="16,0,8,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<!-- 이미지 아이콘 -->
<TextBlock Text="&#xEB9F;" FontFamily="Segoe MDL2 Assets" FontSize="15"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center" Margin="0,1,10,0"/>
<TextBlock Text="이미지 미리보기"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
<TextBlock x:Name="SizeLabel" Text=""
FontSize="11" Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="12,0,0,0"/>
</StackPanel>
<!-- 닫기 버튼 -->
<Border x:Name="BtnClose" HorizontalAlignment="Right" VerticalAlignment="Center"
CornerRadius="4" Padding="8,4" Cursor="Hand"
MouseLeftButtonUp="BtnClose_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#40C05050"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
</Grid>
</Border>
<!-- ─── 이미지 영역 ────────────────────────────────────────────── -->
<ScrollViewer Grid.Row="1" x:Name="ImageScroll"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Background="{DynamicResource LauncherBackground}"
PreviewMouseWheel="ImageScroll_PreviewMouseWheel">
<Grid HorizontalAlignment="Center" VerticalAlignment="Center" Margin="8">
<Image x:Name="PreviewImage"
RenderOptions.BitmapScalingMode="HighQuality"
Stretch="None"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Image.LayoutTransform>
<ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/>
</Image.LayoutTransform>
</Image>
</Grid>
</ScrollViewer>
<!-- ─── 하단 툴바 ─────────────────────────────────────────────── -->
<Border Grid.Row="2" CornerRadius="0,0,12,12"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,1,0,0">
<Grid Margin="12,0">
<!-- 좌측: 줌 컨트롤 -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<!-- 축소 버튼 -->
<Border CornerRadius="6" Padding="8,5" Cursor="Hand" Margin="0,0,2,0"
ToolTip="축소 ()" MouseLeftButtonUp="BtnZoomOut_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ItemHoverBackground}"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE1A9;" FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="{DynamicResource PrimaryText}"/>
</Border>
<!-- 줌 퍼센트 라벨 -->
<Border CornerRadius="4" Padding="6,4" Margin="2,0"
Background="{DynamicResource HintBackground}">
<TextBlock x:Name="ZoomLabel" Text="100%"
FontSize="11" FontWeight="Medium"
Foreground="{DynamicResource PrimaryText}"
MinWidth="44" TextAlignment="Center"/>
</Border>
<!-- 확대 버튼 -->
<Border CornerRadius="6" Padding="8,5" Cursor="Hand" Margin="2,0,6,0"
ToolTip="확대 (+)" MouseLeftButtonUp="BtnZoomIn_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ItemHoverBackground}"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE1A8;" FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="{DynamicResource PrimaryText}"/>
</Border>
<!-- 구분선 -->
<Border Width="1" Height="20" Margin="2,0,8,0"
Background="{DynamicResource BorderColor}"/>
<!-- 실제 크기 -->
<Border CornerRadius="6" Padding="8,5" Cursor="Hand" Margin="0,0,2,0"
ToolTip="실제 크기 (0)" MouseLeftButtonUp="BtnActualSize_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ItemHoverBackground}"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="1:1" FontSize="11" FontWeight="Bold"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
<!-- 창에 맞추기 -->
<Border CornerRadius="6" Padding="8,5" Cursor="Hand"
ToolTip="창에 맞추기 (F)" MouseLeftButtonUp="BtnFitToWindow_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ItemHoverBackground}"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE744;" FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
</StackPanel>
<!-- 우측: 복사 / 저장 버튼 -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<!-- 파일로 저장 -->
<Border CornerRadius="8" Padding="12,6" Cursor="Hand" Margin="0,0,8,0"
ToolTip="파일로 저장 (Ctrl+S)"
MouseLeftButtonUp="BtnSave_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{DynamicResource ItemBackground}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource ItemHoverBackground}"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE74E;" FontFamily="Segoe MDL2 Assets" FontSize="12"
Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center"/>
<TextBlock Text=" 저장" FontSize="12"
Foreground="{DynamicResource SecondaryText}" Margin="4,0,0,0"/>
</StackPanel>
</Border>
<!-- 클립보드 복사 -->
<Border CornerRadius="8" Padding="12,6" Cursor="Hand"
ToolTip="클립보드에 복사 (Ctrl+C)"
MouseLeftButtonUp="BtnCopy_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{DynamicResource AccentColor}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="0.85"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE8C8;" FontFamily="Segoe MDL2 Assets" FontSize="12"
Foreground="White" VerticalAlignment="Center"/>
<TextBlock Text=" 복사" FontSize="12" Foreground="White" Margin="4,0,0,0"/>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,231 @@
using System.IO;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using AxCopilot.Services;
using Microsoft.Win32;
namespace AxCopilot.Views;
/// <summary>
/// Phase L2-3: 클립보드 이미지 미리보기 창.
/// 원본 해상도 이미지 표시 + 확대/축소 + 클립보드 복사 + 파일 저장.
/// Shift+Enter (이미지 항목 선택 시) 또는 런처 내 미리보기 액션으로 열립니다.
/// </summary>
public partial class ClipboardImagePreviewWindow : Window
{
private readonly ClipboardEntry _entry;
private BitmapSource? _fullImage;
private double _zoomFactor = 1.0;
private const double ZoomStep = 0.25;
private const double MinZoom = 0.05;
private const double MaxZoom = 8.0;
public ClipboardImagePreviewWindow(ClipboardEntry entry)
{
InitializeComponent();
_entry = entry;
KeyDown += OnKeyDown;
Loaded += OnLoaded;
}
// ─── 초기화 ──────────────────────────────────────────────────────────
private void OnLoaded(object sender, RoutedEventArgs e)
{
LoadImage();
}
private void LoadImage()
{
// 원본 해상도 이미지 우선, 없으면 썸네일 폴백
_fullImage = ClipboardHistoryService.LoadOriginalImage(_entry.OriginalImagePath)
?? _entry.Image;
if (_fullImage == null)
{
SizeLabel.Text = "(이미지 없음)";
return;
}
PreviewImage.Source = _fullImage;
SizeLabel.Text = $"{_fullImage.PixelWidth} × {_fullImage.PixelHeight} px";
// 창 레이아웃이 확정된 후 Fit 적용
Dispatcher.InvokeAsync(FitToWindow, System.Windows.Threading.DispatcherPriority.Loaded);
}
// ─── 줌 제어 ─────────────────────────────────────────────────────────
private void FitToWindow()
{
if (_fullImage == null) return;
var availW = ImageScroll.ActualWidth - 24;
var availH = ImageScroll.ActualHeight - 24;
if (availW <= 0 || availH <= 0) return;
var scaleX = availW / _fullImage.PixelWidth;
var scaleY = availH / _fullImage.PixelHeight;
_zoomFactor = Math.Min(1.0, Math.Min(scaleX, scaleY));
ApplyZoom();
}
private void ApplyZoom()
{
ZoomTransform.ScaleX = _zoomFactor;
ZoomTransform.ScaleY = _zoomFactor;
ZoomLabel.Text = $"{_zoomFactor * 100:F0}%";
}
// ─── 타이틀바 ────────────────────────────────────────────────────────
private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed)
DragMove();
}
// ─── 버튼 핸들러 ─────────────────────────────────────────────────────
private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close();
private void BtnZoomIn_Click(object sender, MouseButtonEventArgs e)
{
_zoomFactor = Math.Min(MaxZoom, Math.Round(_zoomFactor + ZoomStep, 2));
ApplyZoom();
}
private void BtnZoomOut_Click(object sender, MouseButtonEventArgs e)
{
_zoomFactor = Math.Max(MinZoom, Math.Round(_zoomFactor - ZoomStep, 2));
ApplyZoom();
}
private void BtnActualSize_Click(object sender, MouseButtonEventArgs e)
{
_zoomFactor = 1.0;
ApplyZoom();
}
private void BtnFitToWindow_Click(object sender, MouseButtonEventArgs e)
=> FitToWindow();
private void BtnCopy_Click(object sender, MouseButtonEventArgs e)
=> CopyToClipboard();
private void BtnSave_Click(object sender, MouseButtonEventArgs e)
=> SaveToFile();
// ─── 스크롤 휠 줌 ────────────────────────────────────────────────────
private void ImageScroll_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) return;
e.Handled = true;
var delta = e.Delta > 0 ? ZoomStep : -ZoomStep;
_zoomFactor = Math.Clamp(Math.Round(_zoomFactor + delta, 2), MinZoom, MaxZoom);
ApplyZoom();
}
// ─── 키보드 단축키 ───────────────────────────────────────────────────
private void OnKeyDown(object sender, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
Close();
e.Handled = true;
break;
case Key.Add:
case Key.OemPlus:
_zoomFactor = Math.Min(MaxZoom, Math.Round(_zoomFactor + ZoomStep, 2));
ApplyZoom();
e.Handled = true;
break;
case Key.Subtract:
case Key.OemMinus:
_zoomFactor = Math.Max(MinZoom, Math.Round(_zoomFactor - ZoomStep, 2));
ApplyZoom();
e.Handled = true;
break;
case Key.D0:
case Key.NumPad0:
_zoomFactor = 1.0;
ApplyZoom();
e.Handled = true;
break;
case Key.F:
FitToWindow();
e.Handled = true;
break;
case Key.C when (Keyboard.Modifiers & ModifierKeys.Control) != 0:
CopyToClipboard();
e.Handled = true;
break;
case Key.S when (Keyboard.Modifiers & ModifierKeys.Control) != 0:
SaveToFile();
e.Handled = true;
break;
}
}
// ─── 내부 동작 ───────────────────────────────────────────────────────
private void CopyToClipboard()
{
if (_fullImage == null) return;
try
{
Clipboard.SetImage(_fullImage);
NotificationCenterService.Show("클립보드", "이미지를 클립보드에 복사했습니다.",
NotificationType.Success);
}
catch (Exception ex)
{
NotificationCenterService.Show("복사 실패", ex.Message, NotificationType.Error);
}
}
private void SaveToFile()
{
if (_fullImage == null) return;
var dlg = new SaveFileDialog
{
Title = "이미지 저장",
Filter = "PNG 이미지 (*.png)|*.png|JPEG 이미지 (*.jpg)|*.jpg|BMP 이미지 (*.bmp)|*.bmp",
FileName = $"clipboard_{DateTime.Now:yyyyMMdd_HHmmss}",
DefaultExt = "png",
};
if (dlg.ShowDialog(this) != true) return;
try
{
BitmapEncoder encoder = Path.GetExtension(dlg.FileName).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => new JpegBitmapEncoder { QualityLevel = 95 },
".bmp" => new BmpBitmapEncoder(),
_ => new PngBitmapEncoder(),
};
encoder.Frames.Add(BitmapFrame.Create(_fullImage));
using var fs = File.OpenWrite(dlg.FileName);
encoder.Save(fs);
NotificationCenterService.Show("저장 완료", Path.GetFileName(dlg.FileName),
NotificationType.Success);
}
catch (Exception ex)
{
NotificationCenterService.Show("저장 실패", ex.Message, NotificationType.Error);
}
}
}

View File

@@ -50,6 +50,16 @@ public partial class LauncherWindow
if (shift)
{
// Phase L2-3: 클립보드 이미지 항목 → 이미지 미리보기 창 열기
if (_vm.SelectedItem?.Data is AxCopilot.Services.ClipboardEntry imgEntry && !imgEntry.IsText)
{
e.Handled = true;
Hide();
var previewWin = new ClipboardImagePreviewWindow(imgEntry);
previewWin.Show();
break;
}
// 퍼지 파일 검색 결과: Shift+Enter → 파일이 있는 폴더 열기
if (_vm.SelectedItem?.Data is AxCopilot.Services.IndexEntry shiftEntry)
{

View File

@@ -41,7 +41,7 @@ public partial class LauncherWindow
"Ctrl+Shift+E 탐색기에서 열기",
"Ctrl+Enter 관리자 실행",
"Alt+Enter 속성 보기",
"Shift+Enter 대형 텍스트",
"Shift+Enter 대형 텍스트 / 이미지 미리보기 (#)",
};
CustomMessageBox.Show(