[Phase L5-3] QuickLook 인라인 편집 기능 구현

QuickLookWindow.xaml:
- 타이틀바 우측에 ✏ 편집 버튼(BtnEdit) 추가 (텍스트 파일일 때만 Visible)
  - 닫기 버튼과 StackPanel으로 묶음, 호버 시 #28FFFFFF 배경
- TextEditBox (TextBox) 추가 — TextScrollViewer와 동일 위치(Grid.Row=1)
  - Cascadia Code 12px, Border 없음, AcceptsReturn/Tab, 기본 Collapsed
  - TextChanged → TextEditBox_TextChanged 이벤트 연결

QuickLookWindow.xaml.cs:
- 편집 상태 필드: _currentFilePath, _currentFileExt, _isEditMode, _isModified, _suppressTextChanged
- LoadPreview() 수정:
  - _currentFilePath/_currentFileExt 저장
  - TextExts 파일일 때만 BtnEdit.Visibility=Visible
- BtnEdit_Click / BtnEdit_Click → ToggleEditMode()
- TextEditBox_TextChanged → _isModified=true, 파일명에 "● " 마커 추가
- OnKeyDown 확장:
  - 편집 모드: Ctrl+S(저장), Ctrl+E(토글), Esc(미저장 확인)
  - 미리보기 모드: Ctrl+E(편집 모드 진입)
  - BtnClose_Click: 미저장 확인 후 닫기
- EnterEditMode(): 파일 전체 읽기(300줄 제한 없음), TextEditBox 표시, 아이콘→👁, 상태바 힌트
- ExitEditMode(): TextScrollViewer 복원, 아이콘→✏, ReloadPreview() 호출
- SaveEditedFile(): File.WriteAllText(UTF8), ● 마커 제거, 상태바 갱신, 완료 알림
- ReloadPreview(): 저장 후 PreviewText 새로고침 (편집 내용 반영)

HelpDetailWindow.Shortcuts.cs:
- "기타 창" 카테고리에 "Ctrl+E (QuickLook)" 및 "Ctrl+S (QuickLook 편집 중)" 단축키 추가

docs/LAUNCHER_ROADMAP.md:
- L5-3  완료 표시

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 12:25:25 +09:00
parent ce02343624
commit 3bfa82c06d
4 changed files with 301 additions and 19 deletions

View File

@@ -131,7 +131,7 @@
|---|------|------|----------| |---|------|------|----------|
| L5-1 | **항목별 전용 핫키** ✅ | 앱·URL·폴더에 `Ctrl+Alt+숫자` 등 글로벌 단축키 직접 할당. `hotkey` 프리픽스로 관리. `HotkeyAssignment` 모델 + `InputListener` 확장 + 설정창 "전용 핫키" 탭 | 높음 | | L5-1 | **항목별 전용 핫키** ✅ | 앱·URL·폴더에 `Ctrl+Alt+숫자` 등 글로벌 단축키 직접 할당. `hotkey` 프리픽스로 관리. `HotkeyAssignment` 모델 + `InputListener` 확장 + 설정창 "전용 핫키" 탭 | 높음 |
| L5-2 | **OCR 화면 텍스트 추출** ✅ | `ocr` 프리픽스 + F4 글로벌 단축키. RegionSelectWindow 재사용, Windows.Media.Ocr 로컬 엔진. 결과 → 클립보드 복사 + 런처 입력창 자동 채움 | 높음 | | L5-2 | **OCR 화면 텍스트 추출** ✅ | `ocr` 프리픽스 + F4 글로벌 단축키. RegionSelectWindow 재사용, Windows.Media.Ocr 로컬 엔진. 결과 → 클립보드 복사 + 런처 입력창 자동 채움 | 높음 |
| L5-3 | **QuickLook 인라인 편집** | F3 미리보기에서 텍스트·마크다운 파일 직접 편집 + Ctrl+S 저장. 변경 감지(수정 표시 `●`), Esc 취소 | 중간 | | L5-3 | **QuickLook 인라인 편집** | F3 미리보기 → Ctrl+E 편집 모드 토글. 텍스트/코드 전체 읽기(300줄 제한 없음). Ctrl+S 저장, ● 수정 마커, Esc 취소 확인, 저장 후 미리보기 새로고침 | 중간 |
| L5-4 | **앱 세션 스냅** | 여러 앱을 지정 레이아웃으로 한번에 열기. `snap 세션이름` → 등록된 앱 목록을 각 레이아웃에 배치 | 중간 | | L5-4 | **앱 세션 스냅** | 여러 앱을 지정 레이아웃으로 한번에 열기. `snap 세션이름` → 등록된 앱 목록을 각 레이아웃에 배치 | 중간 |
| L5-5 | **배치 파일 이름 변경** | 다중 선택 후 `rename {패턴}` → 넘버링·날짜·정규식 치환 미리보기 → 일괄 적용 | 중간 | | L5-5 | **배치 파일 이름 변경** | 다중 선택 후 `rename {패턴}` → 넘버링·날짜·정규식 치환 미리보기 → 일괄 적용 | 중간 |
| L5-6 | **자동화 스케줄러** | `sched` 프리픽스로 시간·앱 기반 트리거 등록. "매일 09:00 = 크롬 열기", "캐치 앱 실행 시 = 알림" | 낮음 | | L5-6 | **자동화 스케줄러** | `sched` 프리픽스로 시간·앱 기반 트리거 등록. "매일 09:00 = 크롬 열기", "캐치 앱 실행 시 = 알림" | 낮음 |

View File

@@ -72,6 +72,16 @@ public partial class HelpDetailWindow
"화면 영역 텍스트 추출 (OCR)", "화면 영역 텍스트 추출 (OCR)",
"런처를 닫고 화면 드래그 영역 선택 모드를 즉시 실행합니다. 선택한 영역의 텍스트를 자동으로 인식해 클립보드에 복사하고 런처 입력창에 채웁니다.", "런처를 닫고 화면 드래그 영역 선택 모드를 즉시 실행합니다. 선택한 영역의 텍스트를 자동으로 인식해 클립보드에 복사하고 런처 입력창에 채웁니다.",
"\uE8D2", "#0F766E")); "\uE8D2", "#0F766E"));
// ── QuickLook 편집 ─────────────────────────────────────────────────
items.Add(MakeShortcut("기타 창", "Ctrl + E (QuickLook)",
"QuickLook 편집 모드 전환",
"F3 미리보기 창에서 텍스트/코드 파일을 직접 편집할 수 있습니다. 편집 모드에서 Ctrl+S로 저장, Esc로 취소합니다. 이미지·PDF·Office 파일은 편집 불가입니다.",
"\uE70F", "#6B2C91"));
items.Add(MakeShortcut("기타 창", "Ctrl + S (QuickLook 편집 중)",
"편집 내용 즉시 저장",
"QuickLook 편집 모드에서 현재 편집 내용을 파일에 저장합니다. 저장 완료 시 알림이 표시됩니다.",
"\uE74E", "#059669"));
items.Add(MakeShortcut("런처 기능", "F1", items.Add(MakeShortcut("런처 기능", "F1",
"도움말 창 열기", "도움말 창 열기",
"이 화면을 직접 엽니다. 'help' 를 입력하는 것과 동일합니다.", "이 화면을 직접 엽니다. 'help' 를 입력하는 것과 동일합니다.",

View File

@@ -44,23 +44,49 @@
MaxWidth="270"/> MaxWidth="270"/>
</StackPanel> </StackPanel>
<!-- 닫기 버튼 --> <!-- 우측 버튼 그룹 -->
<Border HorizontalAlignment="Right" VerticalAlignment="Center" <StackPanel HorizontalAlignment="Right" VerticalAlignment="Center"
CornerRadius="4" Padding="8,4" Cursor="Hand" Orientation="Horizontal">
MouseLeftButtonUp="BtnClose_Click">
<Border.Style> <!-- 편집 버튼 (텍스트 파일일 때만 표시) -->
<Style TargetType="Border"> <Border x:Name="BtnEdit"
<Setter Property="Background" Value="Transparent"/> CornerRadius="4" Padding="8,4" Cursor="Hand"
<Style.Triggers> Visibility="Collapsed" Margin="0,0,2,0"
<Trigger Property="IsMouseOver" Value="True"> ToolTip="편집 모드 전환 (Ctrl+E)"
<Setter Property="Background" Value="#40C05050"/> MouseLeftButtonUp="BtnEdit_Click">
</Trigger> <Border.Style>
</Style.Triggers> <Style TargetType="Border">
</Style> <Setter Property="Background" Value="Transparent"/>
</Border.Style> <Style.Triggers>
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets" FontSize="13" <Trigger Property="IsMouseOver" Value="True">
Foreground="{DynamicResource SecondaryText}"/> <Setter Property="Background" Value="#28FFFFFF"/>
</Border> </Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock x:Name="BtnEditIcon"
Text="&#xE70F;"
FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
<!-- 닫기 버튼 -->
<Border 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>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -113,6 +139,23 @@
</Grid> </Grid>
</ScrollViewer> </ScrollViewer>
<!-- 인라인 편집 TextBox (편집 모드에서만 표시) -->
<TextBox x:Name="TextEditBox"
FontFamily="Cascadia Code, Consolas, Courier New"
FontSize="12"
Foreground="{DynamicResource PrimaryText}"
Background="{DynamicResource LauncherBackground}"
BorderThickness="0"
Padding="14,12"
AcceptsReturn="True"
AcceptsTab="True"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
TextWrapping="NoWrap"
SpellCheck.IsEnabled="False"
Visibility="Collapsed"
TextChanged="TextEditBox_TextChanged"/>
<!-- PDF 미리보기 (텍스트 추출) --> <!-- PDF 미리보기 (텍스트 추출) -->
<ScrollViewer x:Name="PdfScrollViewer" <ScrollViewer x:Name="PdfScrollViewer"
HorizontalScrollBarVisibility="Disabled" HorizontalScrollBarVisibility="Disabled"

View File

@@ -54,6 +54,14 @@ public partial class QuickLookWindow : Window
private static readonly HashSet<string> ExcelExts = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> ExcelExts = new(StringComparer.OrdinalIgnoreCase)
{ ".xlsx", ".xls" }; { ".xlsx", ".xls" };
// ─── 편집 상태 ────────────────────────────────────────────────────────────
private string _currentFilePath = ""; // 현재 표시 중인 파일 경로
private string _currentFileExt = ""; // 현재 파일 확장자
private bool _isEditMode; // 편집 모드 활성 여부
private bool _isModified; // 미저장 변경 있음
private bool _suppressTextChanged; // TextChanged 이벤트 억제 플래그
// ─── 생성 ───────────────────────────────────────────────────────────────── // ─── 생성 ─────────────────────────────────────────────────────────────────
public QuickLookWindow(string path, Window owner) public QuickLookWindow(string path, Window owner)
@@ -68,6 +76,50 @@ public partial class QuickLookWindow : Window
private void OnKeyDown(object sender, KeyEventArgs e) private void OnKeyDown(object sender, KeyEventArgs e)
{ {
// ─ 편집 모드 단축키 ─────────────────────────────────────────
if (_isEditMode)
{
// Ctrl+S → 저장
if (e.Key == Key.S && Keyboard.Modifiers == ModifierKeys.Control)
{
SaveEditedFile();
e.Handled = true;
return;
}
// Ctrl+E → 편집 모드 토글
if (e.Key == Key.E && Keyboard.Modifiers == ModifierKeys.Control)
{
ToggleEditMode();
e.Handled = true;
return;
}
// Esc → 편집 모드 종료 (미저장 확인)
if (e.Key == Key.Escape)
{
if (_isModified)
{
var result = CustomMessageBox.Show(
"저장하지 않은 변경 내용이 있습니다.\n편집을 취소하고 미리보기로 돌아갈까요?",
"AX Copilot — 편집 취소",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result != MessageBoxResult.Yes) { e.Handled = true; return; }
}
ExitEditMode();
e.Handled = true;
return;
}
return; // 편집 모드에서 F3 등은 무시 (TextBox가 처리)
}
// ─ 미리보기 모드 단축키 ─────────────────────────────────────
if (e.Key == Key.E && Keyboard.Modifiers == ModifierKeys.Control)
{
ToggleEditMode();
e.Handled = true;
return;
}
if (e.Key is Key.Escape or Key.F3) if (e.Key is Key.Escape or Key.F3)
{ {
Close(); Close();
@@ -80,7 +132,162 @@ public partial class QuickLookWindow : Window
if (e.LeftButton == MouseButtonState.Pressed) DragMove(); if (e.LeftButton == MouseButtonState.Pressed) DragMove();
} }
private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); private void BtnClose_Click(object sender, MouseButtonEventArgs e)
{
if (_isEditMode && _isModified)
{
var result = CustomMessageBox.Show(
"저장하지 않은 변경 내용이 있습니다.\n저장하지 않고 닫을까요?",
"AX Copilot — 저장 확인",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result != MessageBoxResult.Yes) return;
}
Close();
}
// ─── 편집 버튼 클릭 ─────────────────────────────────────────────────────
private void BtnEdit_Click(object sender, MouseButtonEventArgs e) => ToggleEditMode();
// ─── 편집 텍스트 변경 감지 ───────────────────────────────────────────────
private void TextEditBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
{
if (_suppressTextChanged) return;
if (!_isModified)
{
_isModified = true;
// 타이틀에 ● 수정 마커 추가
if (!FileNameText.Text.StartsWith("● "))
FileNameText.Text = "● " + FileNameText.Text;
}
}
// ─── 편집 모드 진입/종료 ─────────────────────────────────────────────────
private void ToggleEditMode()
{
if (!_isEditMode)
EnterEditMode();
else
{
if (_isModified)
{
var result = CustomMessageBox.Show(
"저장하지 않은 변경 내용이 있습니다.\n저장하고 미리보기로 돌아갈까요?",
"AX Copilot — 편집 모드 종료",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Question);
if (result == MessageBoxResult.Cancel) return;
if (result == MessageBoxResult.Yes) SaveEditedFile();
}
ExitEditMode();
}
}
private void EnterEditMode()
{
if (string.IsNullOrEmpty(_currentFilePath) || !TextExts.Contains(_currentFileExt)) return;
try
{
// 파일 전체 내용 읽기 (미리보기의 300줄 제한 없이)
string fullContent;
try { fullContent = File.ReadAllText(_currentFilePath, Encoding.UTF8); }
catch { fullContent = File.ReadAllText(_currentFilePath); }
_suppressTextChanged = true;
TextEditBox.Text = fullContent;
_suppressTextChanged = false;
// UI 전환: 미리보기 → 편집
TextScrollViewer.Visibility = Visibility.Collapsed;
TextEditBox.Visibility = Visibility.Visible;
TextEditBox.Focus();
// 편집 버튼 아이콘 → 👁 (보기 모드로 전환 의미)
BtnEditIcon.Text = "\uE890"; // Eye / View icon
BtnEdit.ToolTip = "미리보기 모드로 전환 (Ctrl+E)";
BtnEditIcon.Foreground = TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(System.Windows.Media.Color.FromRgb(75, 94, 252));
_isEditMode = true;
_isModified = false;
// 상태 힌트를 하단 FooterMeta에 추가
FooterMeta.Text = FooterMeta.Text + " · ✏ 편집 모드 (Ctrl+S 저장 · Esc 취소)";
}
catch (Exception ex)
{
Services.LogService.Warn($"편집 모드 진입 실패: {ex.Message}");
Services.NotificationService.Notify("AX Copilot", $"파일을 열 수 없습니다: {ex.Message}");
}
}
private void ExitEditMode()
{
TextEditBox.Visibility = Visibility.Collapsed;
TextScrollViewer.Visibility = Visibility.Visible;
// 편집 버튼 아이콘 복원 → ✏
BtnEditIcon.Text = "\uE70F";
BtnEdit.ToolTip = "편집 모드 전환 (Ctrl+E)";
BtnEditIcon.Foreground = TryFindResource("SecondaryText") as Brush
?? Brushes.Gray;
_isEditMode = false;
_isModified = false;
// 미리보기 다시 로드 (편집된 내용 반영)
ReloadPreview();
}
private void SaveEditedFile()
{
if (string.IsNullOrEmpty(_currentFilePath)) return;
try
{
File.WriteAllText(_currentFilePath, TextEditBox.Text, Encoding.UTF8);
_isModified = false;
// ● 마커 제거
if (FileNameText.Text.StartsWith("● "))
FileNameText.Text = FileNameText.Text[2..];
// 하단 상태 갱신
var info = new FileInfo(_currentFilePath);
FooterMeta.Text = $"{FormatSize(info.Length)} · {info.LastWriteTime:yyyy-MM-dd HH:mm} · ✏ 편집 모드 (Ctrl+S 저장 · Esc 취소)";
Services.NotificationService.Notify("저장 완료", $"{Path.GetFileName(_currentFilePath)} 저장됨");
Services.LogService.Info($"인라인 편집 저장: {_currentFilePath}");
}
catch (Exception ex)
{
Services.LogService.Error($"파일 저장 실패: {ex.Message}");
CustomMessageBox.Show(
$"파일을 저장하지 못했습니다.\n{ex.Message}",
"저장 오류",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
private void ReloadPreview()
{
// 편집 후 미리보기 새로고침 (변경된 내용 반영)
if (string.IsNullOrEmpty(_currentFilePath) || !File.Exists(_currentFilePath)) return;
// 파일명 ● 마커 제거
if (FileNameText.Text.StartsWith("● "))
FileNameText.Text = FileNameText.Text[2..];
// 상태 바 복원
var info = new FileInfo(_currentFilePath);
FooterMeta.Text = $"{FormatSize(info.Length)} · {info.LastWriteTime:yyyy-MM-dd HH:mm}";
// 텍스트 뷰 갱신
LoadTextPreview(_currentFilePath, _currentFileExt);
}
// ─── 미리보기 로드 ─────────────────────────────────────────────────────── // ─── 미리보기 로드 ───────────────────────────────────────────────────────
@@ -105,21 +312,43 @@ public partial class QuickLookWindow : Window
var ext = Path.GetExtension(path); var ext = Path.GetExtension(path);
var info = new FileInfo(path); var info = new FileInfo(path);
// 편집 상태 저장
_currentFilePath = path;
_currentFileExt = ext;
FooterPath.Text = path; FooterPath.Text = path;
FooterMeta.Text = $"{FormatSize(info.Length)} · {info.LastWriteTime:yyyy-MM-dd HH:mm}"; FooterMeta.Text = $"{FormatSize(info.Length)} · {info.LastWriteTime:yyyy-MM-dd HH:mm}";
if (ImageExts.Contains(ext)) if (ImageExts.Contains(ext))
{
BtnEdit.Visibility = Visibility.Collapsed;
LoadImagePreview(path, info); LoadImagePreview(path, info);
}
else if (PdfExts.Contains(ext)) else if (PdfExts.Contains(ext))
{
BtnEdit.Visibility = Visibility.Collapsed;
LoadPdfPreview(path, info); LoadPdfPreview(path, info);
}
else if (WordExts.Contains(ext)) else if (WordExts.Contains(ext))
{
BtnEdit.Visibility = Visibility.Collapsed;
LoadWordPreview(path, info); LoadWordPreview(path, info);
}
else if (ExcelExts.Contains(ext)) else if (ExcelExts.Contains(ext))
{
BtnEdit.Visibility = Visibility.Collapsed;
LoadExcelPreview(path, info); LoadExcelPreview(path, info);
}
else if (TextExts.Contains(ext)) else if (TextExts.Contains(ext))
{
BtnEdit.Visibility = Visibility.Visible; // ✏ 편집 버튼 표시
LoadTextPreview(path, ext); LoadTextPreview(path, ext);
}
else else
{
BtnEdit.Visibility = Visibility.Collapsed;
LoadFileInfo(path, ext, info); LoadFileInfo(path, ext, info);
}
} }
catch (Exception ex) catch (Exception ex)
{ {