Phase A: tab architecture integrated into MainWindow - tab strip UI, per-tab WebView2 lifecycle, new-window->tab w/ deferral, audio indicator/mute, session save/restore, shortcuts; E2E on real window (56 tests GREEN)

This commit is contained in:
Yun Chan 2026-08-15 14:48:17 +09:00
parent be01a116ca
commit b6b7fc341b
7 changed files with 389 additions and 106 deletions

View file

@ -14,7 +14,6 @@ public sealed class TabItem : INotifyPropertyChanged
/// <summary>CoreWebView2 (초기화 전 null)</summary>
public Microsoft.Web.WebView2.Core.CoreWebView2? Core => View?.CoreWebView2;
private string _title = "새 탭";
public string Title { get => _title; set => Set(ref _title, value); }
@ -45,6 +44,11 @@ public sealed class TabItem : INotifyPropertyChanged
/// <summary>닫힌 시점의 인덱스(닫힌 탭 복원용). 열려 있는 동안은 무의미.</summary>
internal int ClosedIndex { get; set; } = -1;
// ---- 탭별 일시 상태(앱 레이어 전용) ----
internal string? PendingHistoryUrl; // SourceChanged → 타이틀 확정 사이의 기록 대기 URL
internal string? PendingSocialUrl; // 소셜 카드 제목 갱신 대기 URL
internal FindSession? FindSession; // 탭별 페이지 내 검색 세션 (cv 바인딩)
public event PropertyChangedEventHandler? PropertyChanged;
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)

View file

@ -153,6 +153,7 @@
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
@ -209,8 +210,68 @@
</DockPanel>
</Border>
<!-- ===== 탭 스트립 (Phase A) ===== -->
<Border x:Name="TabStripRow" Grid.Row="1" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1" Padding="8,4,8,0">
<DockPanel>
<Button x:Name="NewTabBtn" DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Content="&#xE710;" FontSize="13" ToolTip="새 탭 (Ctrl+T)" Click="NewTab_Click"
Margin="4,0,2,3" VerticalAlignment="Center"/>
<ScrollViewer HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
VerticalAlignment="Bottom">
<ItemsControl x:Name="TabList">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,4,0" MinWidth="130" MaxWidth="220">
<Border x:Name="TabBg" CornerRadius="8,8,0,0"
Background="{StaticResource GlassBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="1,1,1,0"
Padding="10,7,4,7" MouseLeftButtonUp="Tab_Click"
PreviewMouseDown="Tab_PreviewMouseDown"
ToolTip="{Binding Url}">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}"
Content="&#xE711;" FontSize="9" Padding="5,3" Margin="4,0,0,0"
Click="TabClose_Click" ToolTip="탭 닫기 (Ctrl+W)"
VerticalAlignment="Center"/>
<TextBlock x:Name="AudioIcon" Text="&#xE767;" Style="{StaticResource FluentIcon}"
FontSize="11" Margin="0,0,6,0" VerticalAlignment="Center"
Visibility="Collapsed" Cursor="Hand"
MouseLeftButtonUp="TabAudio_Click"/>
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis"
FontSize="12" VerticalAlignment="Center"/>
</DockPanel>
</Border>
</Grid>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsActive}" Value="True">
<Setter TargetName="TabBg" Property="Background" Value="{StaticResource CardBg}"/>
<Setter TargetName="TabBg" Property="BorderBrush" Value="{StaticResource AccentFadeBrush}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsAudioPlaying}" Value="True">
<Setter TargetName="AudioIcon" Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsMuted}" Value="True">
<Setter TargetName="AudioIcon" Property="Text" Value="&#xE74F;"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsPinned}" Value="True">
<Setter TargetName="TabBg" Property="Background" Value="{StaticResource AccentFadeBrush}"/>
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<!-- ===== 본문 ===== -->
<Grid Grid.Row="1">
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@ -224,7 +285,7 @@
</Grid.RowDefinitions>
<Border Grid.Row="0" ClipToBounds="True" Background="Black">
<wv2:WebView2 Name="Browser"/>
<Grid x:Name="TabHost" Background="Black"/>
</Border>
<!-- 페이지 내 검색 바 (T1-3): WebView2(airspace) 아래에 도킹 -->
@ -320,7 +381,7 @@
</Grid>
<!-- ===== 상태 표시줄 ===== -->
<Border x:Name="StatusBar" Grid.Row="2" Background="{StaticResource PanelBg}"
<Border x:Name="StatusBar" Grid.Row="3" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5">
<TextBlock Name="StatusText" Foreground="{StaticResource TextMuted}" FontSize="11"
TextTrimming="CharacterEllipsis" Text="준비"/>

View file

@ -10,10 +10,13 @@ using System.Windows.Interop;
using System.Windows.Media;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.App.Browser;
using VideoDownloader.App.Browser.Tabs;
using VideoDownloader.App.Core;
using VideoDownloader.App.Detection;
using VideoDownloader.App.ViewModels;
using VideoDownloader.Core.Config;
using Wv2 = Microsoft.Web.WebView2.Wpf;
using TabItem = VideoDownloader.App.Browser.Tabs.TabItem;
namespace VideoDownloader.App;
@ -23,18 +26,22 @@ public partial class MainWindow : Window
private AppConfig _config;
private int _totalRequests;
private string _appLogPath = "";
private string _currentTitle = "";
// ---- 탭 아키텍처 (Phase A) ----
private CoreWebView2Environment? _env;
private TabManager _tabs = null!;
private readonly Dictionary<TabItem, Task> _tabInit = new();
internal TabManager Tabs => _tabs;
internal Wv2.WebView2? ActiveView => _tabs?.ActiveTab?.View;
private CoreWebView2? Cv => _tabs?.ActiveTab?.Core;
private readonly ObservableCollection<Bookmark> _bookmarks = new();
private readonly ObservableCollection<HistoryEntry> _history = new();
private string? _pendingHistoryUrl;
private string? _pendingSocialUrl; // 타이틀 확정 시 소셜 카드 제목 갱신용
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
// T1-4 검색엔진 / T1-7 사이트별 줌 / T1-3 페이지 내 검색 / T1-11 전체화면
private SearchEngineResolver _resolver = null!;
private SiteZoomStore _zoomStore = null!;
private FindSession? _findSession;
private bool _fullscreen;
private WindowState _stateBeforeFullscreen = WindowState.Normal;
@ -51,6 +58,8 @@ public partial class MainWindow : Window
private const double MinExpandedWidth = 280;
private const double MaxExpandedWidth = 900;
private static string SessionPath => Path.Combine(AppDataDir, "session.json");
public MainWindow()
{
InitializeComponent();
@ -142,44 +151,144 @@ public partial class MainWindow : Window
internal static string ProfileFolder => Path.Combine(AppDataDir, "webview");
// ==================== 탭 생명주기 (Phase A) ====================
private async Task InitializeBrowserAsync()
{
CoreWebView2Environment? env = null;
try
{
// 명시적 영속 프로필: 기본(exe 디렉터리) 폴더는 빌드/재배포 시 날아가 로그인이 풀림 → %AppData% 로 고정.
Directory.CreateDirectory(ProfileFolder);
env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: ProfileFolder);
var options = env.CreateCoreWebView2ControllerOptions();
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
await Browser.EnsureCoreWebView2Async(env, options);
_env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: ProfileFolder);
}
catch (Exception ex) { Log($"WebView2 초기화 실패: {ex.Message}"); return; }
var cv = Browser.CoreWebView2;
_tabs = new TabManager(CreateTabItem, AttachTabView);
_tabs.ActiveChanged += OnActiveTabChanged;
_tabs.TabRemoved += OnTabRemoved;
TabList.ItemsSource = _tabs.Tabs;
UpdatePrivateButton();
// T1-10 세션 복원: 저장된 탭이 있으면 복원, 아니면 홈 탭
var snap = _config.RestoreSession ? TabSessionStore.Load(SessionPath) : null;
if (snap != null && snap.Urls.Count > 0)
_tabs.RestoreSession(snap);
else
_tabs.NewTab(_config.HomeUrl);
UpdateNavState();
}
private TabItem CreateTabItem() => new();
/// <summary>탭에 WebView2 뷰를 붙이고 비동기 초기화를 시작한다. (UI 스레드)</summary>
private void AttachTabView(TabItem tab)
{
var view = new Wv2.WebView2 { DefaultBackgroundColor = System.Drawing.Color.Black };
tab.View = view;
TabHost.Children.Add(view);
view.Visibility = tab.IsActive ? Visibility.Visible : Visibility.Collapsed;
_tabInit[tab] = InitTabCoreAsync(tab);
}
/// <summary>탭의 CoreWebView2 초기화 + 탭별 이벤트 배선 + 초기 URL 탐색.</summary>
private async Task InitTabCoreAsync(TabItem tab)
{
try
{
var options = _env!.CreateCoreWebView2ControllerOptions();
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
await tab.View!.EnsureCoreWebView2Async(_env, options);
}
catch (Exception ex)
{
Log($"탭 초기화 실패: {ex.Message}");
return;
}
var cv = tab.Core!;
cv.Settings.AreDevToolsEnabled = true;
// T1-3 네이티브 Find API 세션 (자체 검색 바)
_findSession = new FindSession(new WebView2FindApi(cv, env!));
_findSession.StateChanged += () => Dispatcher.BeginInvoke(new Action(() =>
// T1-3 탭별 검색 세션 (네이티브 Find API)
tab.FindSession = new FindSession(new WebView2FindApi(cv, _env!));
tab.FindSession.StateChanged += () => Dispatcher.BeginInvoke(new Action(() =>
{
if (_findSession != null) FindStatus.Text = _findSession.StatusText;
if (tab.IsActive && tab.FindSession != null) FindStatus.Text = tab.FindSession.StatusText;
}));
// T1-7 사이트별 줌: 네비 완료 시 저장된 배율 적용 (WebView2 는 사이트별 영속 미보장)
// 탭별 네비게이션 상태
cv.NavigationStarting += (_, _) => Dispatcher.BeginInvoke(new Action(() => tab.IsLoading = true));
cv.NavigationCompleted += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
try { Browser.ZoomFactor = _zoomStore.Get(cv.Source); } catch { }
tab.IsLoading = false;
try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7
}));
cv.HistoryChanged += (_, _) => UpdateNavState();
// 소셜/주소창/기록: 탭별 상태로 관리
cv.SourceChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
var url = cv.Source ?? "";
tab.Url = url;
if (tab.PendingHistoryUrl != null) RecordHistory(tab.PendingHistoryUrl, tab.PendingHistoryUrl);
if (tab.IsActive && !AddressBox.IsKeyboardFocused) AddressBox.Text = url;
tab.PendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url;
DetectSocial(tab, url);
}));
cv.DocumentTitleChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
var title = cv.DocumentTitle ?? "";
tab.Title = string.IsNullOrWhiteSpace(title) ? (tab.Url.StartsWith("http") ? tab.Url : "새 탭") : title;
if (tab.PendingHistoryUrl != null)
{
RecordHistory(string.IsNullOrWhiteSpace(title) ? tab.PendingHistoryUrl : title, tab.PendingHistoryUrl);
tab.PendingHistoryUrl = null;
}
// 소셜 카드 제목을 페이지 타이틀로 갱신(출력 파일명 포함, 시작 전일 때)
if (tab.PendingSocialUrl != null && !string.IsNullOrWhiteSpace(title))
{
_manager.RenameSocial(new Uri(tab.PendingSocialUrl), title);
tab.PendingSocialUrl = null;
}
}));
cv.WebResourceResponseReceived += OnResponseReceived;
// #4 팝업/새창을 현재 WebView2 로 강제 라우팅
cv.NewWindowRequested += (_, ne) =>
// T1-8 오디오 표시/뮤트
try
{
try { if (ne.Uri != null) { ne.Handled = true; cv.Navigate(ne.Uri); } }
catch { }
cv.IsDocumentPlayingAudioChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
try { tab.IsAudioPlaying = cv.IsDocumentPlayingAudio; } catch { }
}));
cv.IsMutedChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
try { tab.IsMuted = cv.IsMuted; } catch { }
}));
}
catch { } // 구현체/타이밍 이슈 대비
// 미디어 감지(탭 무관 전역)
cv.WebResourceResponseReceived += OnResponseReceived;
// Phase A: 팝업/새창 → 새 탭 (deferral 필수 — 탭 생성이 비동기)
cv.NewWindowRequested += async (_, ne) =>
{
try
{
var deferral = ne.GetDeferral();
try
{
var newTab = _tabs.NewTab(activate: false); // URL 없이 생성 → 팝업 내용이 새 창으로 흘러들어옴
await WaitTabReady(newTab);
ne.NewWindow = newTab.Core;
ne.Handled = true;
}
finally { deferral.Complete(); }
}
catch
{
try { ne.Handled = true; if (ne.Uri != null) cv.Navigate(ne.Uri); } catch { }
}
};
// #13 페이지 내 다운로드(a[download])를 저장 폴더로 라우팅
cv.DownloadStarting += (_, de) =>
{
@ -194,46 +303,90 @@ public partial class MainWindow : Window
}
catch { }
};
cv.HistoryChanged += (_, _) => UpdateNavState();
cv.SourceChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
if (_pendingHistoryUrl != null) RecordHistory(_pendingHistoryUrl, _pendingHistoryUrl);
var url = cv.Source ?? "";
AddressBox.Text = url;
_pendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url;
DetectSocial(url);
}));
cv.DocumentTitleChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
_currentTitle = cv.DocumentTitle ?? "";
if (_pendingHistoryUrl != null)
{
RecordHistory(string.IsNullOrWhiteSpace(_currentTitle) ? _pendingHistoryUrl : _currentTitle, _pendingHistoryUrl);
_pendingHistoryUrl = null;
}
// 소셜 카드 제목을 페이지 타이틀로 갱신(출력 파일명 포함, 시작 전일 때)
if (_pendingSocialUrl != null && !string.IsNullOrWhiteSpace(_currentTitle))
{
_manager.RenameSocial(new Uri(_pendingSocialUrl), _currentTitle);
_pendingSocialUrl = null;
}
}));
NavHome_Click(this, null!);
// 초기 URL (세션 복원/빈 탭 제외)
if (!string.IsNullOrEmpty(tab.Url) && (cv.Source == null || cv.Source.Length == 0))
{
try { cv.Navigate(tab.Url); } catch { }
}
}
/// <summary>탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용).</summary>
private async Task WaitTabReady(TabItem tab)
{
if (tab.Core != null) return;
if (_tabInit.TryGetValue(tab, out var t)) { try { await t; } catch { } return; }
var sw = System.Diagnostics.Stopwatch.StartNew();
while (tab.Core == null && sw.ElapsedMilliseconds < 15000)
await Task.Delay(50);
}
private void OnActiveTabChanged(TabItem tab)
{
foreach (var t in _tabs.Tabs)
if (t.View != null) t.View.Visibility = ReferenceEquals(t, tab) ? Visibility.Visible : Visibility.Collapsed;
if (!AddressBox.IsKeyboardFocused) AddressBox.Text = tab.Url;
UpdateNavState();
}
private void OnTabRemoved(TabItem tab)
{
if (tab.View != null)
{
TabHost.Children.Remove(tab.View);
tab.View = null; // 재오픈 시 새 뷰로 재생성(ReopenClosedTab → AttachTabView)
}
_tabInit.Remove(tab);
}
private void UpdateNavState()
{
var cv = Browser.CoreWebView2;
if (cv == null) return;
var cv = Cv;
Dispatcher.BeginInvoke(new Action(() =>
{
NavBack.IsEnabled = cv.CanGoBack;
NavForward.IsEnabled = cv.CanGoForward;
NavBack.IsEnabled = cv?.CanGoBack == true;
NavForward.IsEnabled = cv?.CanGoForward == true;
}));
}
// ---- 탭 스트립 UI 이벤트 ----
private void NewTab_Click(object sender, RoutedEventArgs e) => _tabs.NewTab(_config.HomeUrl);
private void Tab_Click(object sender, MouseButtonEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is TabItem tab)
_tabs.Activate(tab);
}
private void Tab_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
// 휠 클릭(미들) → 탭 닫기 (브라우저 관례)
if (e.ChangedButton == MouseButton.Middle && e.ButtonState == MouseButtonState.Pressed)
{
if ((sender as FrameworkElement)?.DataContext is TabItem tab)
{
_tabs.CloseTab(tab);
e.Handled = true;
}
}
}
private void TabClose_Click(object sender, RoutedEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is TabItem tab)
_tabs.CloseTab(tab);
}
// T1-8 스피커 아이콘 클릭 → 음소거 토글
private void TabAudio_Click(object sender, MouseButtonEventArgs e)
{
if ((sender as FrameworkElement)?.DataContext is TabItem { Core: { } cv } tab)
{
try { cv.IsMuted = !cv.IsMuted; tab.IsMuted = cv.IsMuted; } catch { }
}
e.Handled = true;
}
private void OnResponseReceived(object? sender, CoreWebView2WebResourceResponseReceivedEventArgs e)
{
try
@ -273,7 +426,7 @@ public partial class MainWindow : Window
}
catch { }
string referer = Browser.CoreWebView2?.Source ?? "";
string referer = (sender as CoreWebView2)?.Source ?? Cv?.Source ?? "";
try { var r = e.Request.Headers.GetHeader("Referer"); if (!string.IsNullOrEmpty(r)) referer = r; } catch { }
var kind = MediaDetector.Classify(uri, ct, len, _config);
@ -348,7 +501,7 @@ public partial class MainWindow : Window
private async Task<List<CoreWebView2Cookie>> GetCookiesAsync(Uri url)
{
var cv = Browser.CoreWebView2;
var cv = Cv;
if (cv == null) return new();
try { return (await cv.CookieManager.GetCookiesAsync(url.ToString())).ToList(); }
catch { return new(); }
@ -455,9 +608,9 @@ public partial class MainWindow : Window
}
}
private void NavBack_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.GoBack();
private void NavForward_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.GoForward();
private void NavRefresh_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.Reload();
private void NavBack_Click(object sender, RoutedEventArgs e) => Cv?.GoBack();
private void NavForward_Click(object sender, RoutedEventArgs e) => Cv?.GoForward();
private void NavRefresh_Click(object sender, RoutedEventArgs e) => Cv?.Reload();
private void NavHome_Click(object sender, RoutedEventArgs e) => NavigateTo(_config.HomeUrl);
internal void NavigateTo(string? url)
@ -465,21 +618,21 @@ public partial class MainWindow : Window
// T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당
var intent = _resolver.Resolve(url);
if (intent == null) return;
Browser.CoreWebView2?.Navigate(intent.Target);
Cv?.Navigate(intent.Target);
}
// 소셜 영상 페이지(YouTube/Instagram/X/...) 감지 → yt-dlp 카드 등록.
private void DetectSocial(string? url)
// 소셜 영상 페이지(YouTube/Instagram/X/...) 감지 → yt-dlp 카드 등록. (탭별 상태)
private void DetectSocial(TabItem tab, string? url)
{
if (string.IsNullOrEmpty(url)) { _pendingSocialUrl = null; return; }
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { _pendingSocialUrl = null; return; }
if (string.IsNullOrEmpty(url)) { tab.PendingSocialUrl = null; return; }
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { tab.PendingSocialUrl = null; return; }
if (SocialSiteDetector.TryMatch(uri, out var normalized, out var label))
{
_manager.LogPanel($"★ 소셜 감지 [{label}]: {normalized}");
_manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label));
_pendingSocialUrl = normalized;
tab.PendingSocialUrl = normalized;
}
else _pendingSocialUrl = null;
else tab.PendingSocialUrl = null;
}
// ---- 링크로 받기 (URL 붙여넣기) ----
@ -506,27 +659,40 @@ public partial class MainWindow : Window
{
_manager.LogPanel($"★ 소셜 감지 [{label}]: {normalized}");
_manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label));
_pendingSocialUrl = normalized;
LinkPopup.IsOpen = false;
Log("카드에 추가했습니다. 화질을 선택하고 받기를 누르세요");
}
else Log("지원하는 소셜 영상 링크가 아닙니다 (YouTube·Instagram·X·TikTok·Facebook)");
}
// #7 전역 키보드 단축키 (+T1-3/T1-9/T1-11)
// #7 전역 키보드 단축키 (+Phase A 탭/T1-3/T1-9/T1-11)
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
var cv = Browser.CoreWebView2;
var cv = Cv;
var mods = Keyboard.Modifiers;
// Phase A 탭 단축키
if (mods == ModifierKeys.Control && e.Key == Key.T) { _tabs.NewTab(_config.HomeUrl); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.T) { _tabs.ReopenClosedTab(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.W) { _tabs.CloseTab(_tabs.ActiveTab!); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.W) { if (_tabs.ActiveTab != null) _tabs.CloseTab(_tabs.ActiveTab); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.Tab) { _tabs.ActivateNext(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.Tab) { _tabs.ActivatePrevious(); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key >= Key.D1 && e.Key <= Key.D9)
{
var idx = e.Key - Key.D1;
if (idx == 8) _tabs.ActivateLast(); else _tabs.ActivateAt(idx); // Ctrl+9 = 마지막 탭
e.Handled = true;
}
// T1-3 검색 바가 열려 있으면 Esc 는 닫기가 우선
if (e.Key == Key.Escape && FindBar.Visibility == Visibility.Visible) { CloseFind(); e.Handled = true; }
else if (e.Key == Key.Escape && FindBar.Visibility == Visibility.Visible) { CloseFind(); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.F) { OpenFind(); e.Handled = true; }
else if (e.Key == Key.F3)
{
if (FindBar.Visibility == Visibility.Visible)
{
if (mods == ModifierKeys.Shift) _findSession?.Previous(); else _findSession?.Next();
if (mods == ModifierKeys.Shift) _tabs.ActiveTab?.FindSession?.Previous();
else _tabs.ActiveTab?.FindSession?.Next();
}
else OpenFind();
e.Handled = true;
@ -540,66 +706,80 @@ public partial class MainWindow : Window
else if (mods == ModifierKeys.Alt && e.Key == Key.Right) { try { if (cv?.CanGoForward == true) cv.GoForward(); } catch { } e.Handled = true; }
else if (mods == ModifierKeys.Control)
{
if (e.Key == Key.OemPlus || e.Key == Key.Add) { try { Browser.ZoomFactor = Math.Min(Browser.ZoomFactor + 0.1, 5); PersistZoom(); } catch { } e.Handled = true; }
else if (e.Key == Key.OemMinus || e.Key == Key.Subtract) { try { Browser.ZoomFactor = Math.Max(Browser.ZoomFactor - 0.1, 0.1); PersistZoom(); } catch { } e.Handled = true; }
else if (e.Key == Key.D0) { try { Browser.ZoomFactor = 1.0; PersistZoom(); } catch { } e.Handled = true; }
if (e.Key == Key.OemPlus || e.Key == Key.Add) { try { ZoomBy(+0.1); } catch { } e.Handled = true; }
else if (e.Key == Key.OemMinus || e.Key == Key.Subtract) { try { ZoomBy(-0.1); } catch { } e.Handled = true; }
else if (e.Key == Key.D0) { try { ZoomSet(1.0); } catch { } e.Handled = true; }
}
}
// ---- T1-3 페이지 내 검색 바 ----
private void ZoomBy(double delta) => ZoomSet(Math.Clamp((ActiveView?.ZoomFactor ?? 1.0) + delta, 0.1, 5.0));
private void ZoomSet(double zoom)
{
if (ActiveView == null) return;
ActiveView.ZoomFactor = zoom;
PersistZoom();
}
// ---- T1-3 페이지 내 검색 바 (활성 탭 세션) ----
internal void OpenFind()
{
FindBar.Visibility = Visibility.Visible;
FindStatus.Text = "";
FindBox.Focus();
FindBox.SelectAll();
if (FindBox.Text.Length > 0) _ = _findSession?.StartAsync(FindBox.Text);
if (FindBox.Text.Length > 0) _ = _tabs.ActiveTab?.FindSession?.StartAsync(FindBox.Text);
}
internal void CloseFind()
{
FindBar.Visibility = Visibility.Collapsed;
_findSession?.Close();
_tabs.ActiveTab?.FindSession?.Close();
FindStatus.Text = "";
}
private void FindBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (FindBar.Visibility == Visibility.Visible)
_ = _findSession?.StartAsync(FindBox.Text);
_ = _tabs.ActiveTab?.FindSession?.StartAsync(FindBox.Text);
}
private void FindBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter) { if (Keyboard.Modifiers == ModifierKeys.Shift) _findSession?.Previous(); else _findSession?.Next(); e.Handled = true; }
if (e.Key == Key.Enter)
{
if (Keyboard.Modifiers == ModifierKeys.Shift) _tabs.ActiveTab?.FindSession?.Previous();
else _tabs.ActiveTab?.FindSession?.Next();
e.Handled = true;
}
else if (e.Key == Key.Escape) { CloseFind(); e.Handled = true; }
}
private void FindNext_Click(object sender, RoutedEventArgs e) => _findSession?.Next();
private void FindPrev_Click(object sender, RoutedEventArgs e) => _findSession?.Previous();
private void FindNext_Click(object sender, RoutedEventArgs e) => _tabs.ActiveTab?.FindSession?.Next();
private void FindPrev_Click(object sender, RoutedEventArgs e) => _tabs.ActiveTab?.FindSession?.Previous();
private void FindClose_Click(object sender, RoutedEventArgs e) => CloseFind();
private void FindOption_Changed(object sender, RoutedEventArgs e)
=> _findSession?.SetOptions(FindCaseBox.IsChecked == true, FindWordBox.IsChecked == true);
=> _tabs.ActiveTab?.FindSession?.SetOptions(FindCaseBox.IsChecked == true, FindWordBox.IsChecked == true);
// T1-7 줌 변경을 사이트별 저장소에 영속
private void PersistZoom()
{
var cv = Browser.CoreWebView2;
if (cv != null) _zoomStore.Set(cv.Source, Browser.ZoomFactor);
var cv = Cv;
if (cv != null && ActiveView != null) _zoomStore.Set(cv.Source, ActiveView.ZoomFactor);
}
// T1-9 강제 새로고침: 디스크 캐시 비우고 다시 로드
private async void ForceReload()
{
var cv = Browser.CoreWebView2;
var cv = Cv;
if (cv == null) return;
try { await cv.Profile.ClearBrowsingDataAsync(CoreWebView2BrowsingDataKinds.DiskCache); }
catch { }
try { cv.Reload(); } catch { }
}
// T1-11 전체화면(F11): 툴바/상태바 숨기고 최대화. 재누르면 복원.
// T1-11 전체화면(F11): 툴바/탭/상태바 숨기고 최대화. 재누르면 복원.
internal void ToggleFullscreen()
{
_fullscreen = !_fullscreen;
@ -613,6 +793,7 @@ public partial class MainWindow : Window
WindowState = _stateBeforeFullscreen;
}
ToolbarRow.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
TabStripRow.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
StatusBar.Visibility = _fullscreen ? Visibility.Collapsed : Visibility.Visible;
}
@ -672,6 +853,8 @@ public partial class MainWindow : Window
_config.RightPanelCollapsed = _panelCollapsed;
if (!_panelCollapsed) _config.RightPanelWidth = ClampPanelWidth(RightCol.Width.Value);
ConfigStore.Save(_config);
// T1-10 세션 저장: 탭 URL/활성/핀 보존
try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { }
}
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
@ -707,7 +890,7 @@ public partial class MainWindow : Window
/// <summary>현재 프로필의 브라우저 데이터(쿠키·DOM 저장소·캐시) 삭제. 일반 모드: 영속 프로필 정리.</summary>
public async Task ClearBrowsingDataAsync()
{
var cv = Browser.CoreWebView2;
var cv = Cv;
if (cv == null) { Log("브라우저가 아직 준비되지 않았습니다"); return; }
try
{
@ -727,7 +910,7 @@ public partial class MainWindow : Window
private void AddBookmark_Click(object sender, RoutedEventArgs e)
{
var cv = Browser.CoreWebView2;
var cv = Cv;
var url = cv?.Source ?? "";
if (string.IsNullOrEmpty(url) || url.StartsWith("about:", StringComparison.OrdinalIgnoreCase)) return;
if (_bookmarks.Any(b => b.Url == url)) { Log("이미 즐겨찾기에 있습니다"); return; }