Phase C (T2-1..T3-10): command palette, reader mode (real Readability.js, E2E), boost force-dark, screenshot, split view, vertical tabs, Glance (Alt+click), tab groups (6 colors), workspaces, speed dial, InPrivate tabs, quick find, source view, print, PiP, TTS (94 tests GREEN)
This commit is contained in:
parent
0caa1111e7
commit
406957088f
14 changed files with 3496 additions and 7 deletions
|
|
@ -10,6 +10,7 @@ using System.Windows.Interop;
|
|||
using System.Windows.Media;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using VideoDownloader.Browser;
|
||||
using VideoDownloader.App.Browser;
|
||||
using VideoDownloader.App.Browser.Features;
|
||||
using VideoDownloader.Browser.Tabs;
|
||||
using VideoDownloader.App.Core;
|
||||
|
|
@ -39,6 +40,14 @@ public partial class MainWindow : Window
|
|||
private readonly ObservableCollection<HistoryEntry> _history = new();
|
||||
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
|
||||
|
||||
// Phase C: 팔레트/부스트/워크스페이스/레이아웃
|
||||
private readonly CommandPalette _palette = new();
|
||||
private BoostStore _boostStore = null!;
|
||||
private List<WorkspaceSnapshot> _workspaces = new();
|
||||
private bool _verticalTabs;
|
||||
private TabItem? _splitTab;
|
||||
private System.Speech.Synthesis.SpeechSynthesizer? _speech;
|
||||
|
||||
// Phase B: 다운로드 매니저(T1-5) / 탭 절전(T2-2)
|
||||
private readonly PageDownloadTracker _pageDownloads = new();
|
||||
private System.Windows.Threading.DispatcherTimer? _speedTimer;
|
||||
|
|
@ -84,6 +93,15 @@ public partial class MainWindow : Window
|
|||
|
||||
_resolver = new SearchEngineResolver(_config.SearchEngine);
|
||||
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
|
||||
_boostStore = new BoostStore(Path.Combine(AppDataDir, "boost.json"));
|
||||
try
|
||||
{
|
||||
var wsPath = Path.Combine(AppDataDir, "workspaces.json");
|
||||
if (File.Exists(wsPath))
|
||||
_workspaces = System.Text.Json.JsonSerializer.Deserialize<List<WorkspaceSnapshot>>(File.ReadAllText(wsPath)) ?? new();
|
||||
}
|
||||
catch { }
|
||||
BuildPalette();
|
||||
|
||||
Loaded += async (_, _) => await InitializeBrowserAsync();
|
||||
PreviewKeyDown += OnPreviewKeyDown;
|
||||
|
|
@ -184,8 +202,14 @@ public partial class MainWindow : Window
|
|||
};
|
||||
|
||||
_views.ContextMenuBuild += OnContextMenuBuild;
|
||||
_views.BoostScriptProvider = url => BoostCssBuilder.BuildScript(url, _boostStore.Get(url)); // T2-7
|
||||
_views.GlanceRequested += (_, link) => Dispatcher.BeginInvoke(new Action(() => OpenGlance(link))); // T2-6
|
||||
_views.SpeakRequested += (_, text) => Dispatcher.BeginInvoke(new Action(() => SpeakText(text))); // T3-9
|
||||
|
||||
_views.ContextMenuBuild += OnContextMenuBuild;
|
||||
_views.BoostScriptProvider = url => BoostCssBuilder.BuildScript(url, _boostStore.Get(url)); // T2-7
|
||||
_views.GlanceRequested += (_, link) => Dispatcher.BeginInvoke(new Action(() => OpenGlance(link))); // T2-6
|
||||
_views.SpeakRequested += (_, text) => Dispatcher.BeginInvoke(new Action(() => SpeakText(text))); // T3-9
|
||||
|
||||
_tabs = new TabManager(() => new TabItem(), _views.AttachView);
|
||||
_tabs.ActiveChanged += OnActiveTabChanged;
|
||||
|
|
@ -299,6 +323,7 @@ public partial class MainWindow : Window
|
|||
|
||||
private void OnActiveTabChanged(TabItem tab)
|
||||
{
|
||||
if (_splitTab != null && !ReferenceEquals(tab, _splitTab)) ExitSplit(); // v1: 전환 시 분할 해제
|
||||
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;
|
||||
|
|
@ -310,7 +335,11 @@ public partial class MainWindow : Window
|
|||
UpdateNavState();
|
||||
}
|
||||
|
||||
private void OnTabRemoved(TabItem tab) => _views?.RemoveView(tab);
|
||||
private void OnTabRemoved(TabItem tab)
|
||||
{
|
||||
if (ReferenceEquals(_splitTab, tab)) ExitSplit();
|
||||
_views?.RemoveView(tab);
|
||||
}
|
||||
|
||||
private void UpdateNavState()
|
||||
{
|
||||
|
|
@ -323,7 +352,7 @@ public partial class MainWindow : Window
|
|||
}
|
||||
|
||||
// ---- 탭 스트립 UI 이벤트 ----
|
||||
private void NewTab_Click(object sender, RoutedEventArgs e) => _tabs.NewTab(_config.HomeUrl);
|
||||
private void NewTab_Click(object sender, RoutedEventArgs e) => NewTabWithDial();
|
||||
|
||||
private void Tab_Click(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
|
|
@ -645,8 +674,18 @@ public partial class MainWindow : Window
|
|||
var mods = Keyboard.Modifiers;
|
||||
|
||||
// Phase A 탭 단축키
|
||||
if (mods == ModifierKeys.Control && e.Key == Key.T) { _tabs.NewTab(_config.HomeUrl); e.Handled = true; }
|
||||
if (mods == ModifierKeys.Control && e.Key == Key.T) { NewTabWithDial(); e.Handled = true; }
|
||||
else if (mods == ModifierKeys.Control && e.Key == Key.J) { DownloadsPopup.IsOpen = !DownloadsPopup.IsOpen; e.Handled = true; }
|
||||
else if (mods == ModifierKeys.Control && e.Key == Key.K) { TogglePalette(); e.Handled = true; }
|
||||
else if (e.Key == Key.F9) { _ = ToggleReader(); e.Handled = true; }
|
||||
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.D) { ToggleBoostDark(); e.Handled = true; }
|
||||
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.S) { _ = TakeScreenshot(); e.Handled = true; }
|
||||
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.N) { OpenInPrivateTab(); e.Handled = true; }
|
||||
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.V) { ToggleVerticalTabs(); e.Handled = true; }
|
||||
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.H) { ToggleSplit(); e.Handled = true; }
|
||||
else if (mods == ModifierKeys.Control && e.Key == Key.U) { ViewSource(); e.Handled = true; }
|
||||
else if (mods == ModifierKeys.Control && e.Key == Key.P) { try { Cv?.ShowPrintUI(); } catch { } e.Handled = true; }
|
||||
else if (e.Key == Key.OemQuestion && Keyboard.FocusedElement is not TextBox) { OpenFind(); e.Handled = true; }
|
||||
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.A) { ToggleTabSearch(this, e); e.Handled = true; }
|
||||
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.B) { ToggleBookmarkBar(); e.Handled = true; }
|
||||
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.T) { _tabs.ReopenClosedTab(); e.Handled = true; }
|
||||
|
|
@ -1079,6 +1118,363 @@ public partial class MainWindow : Window
|
|||
_manager.OnDetected(new DetectionContext(link, referer, kind, ""));
|
||||
}
|
||||
|
||||
// ==================== Phase C 기능 ====================
|
||||
|
||||
// ---- T2-1 명령 팔레트 ----
|
||||
private void BuildPalette()
|
||||
{
|
||||
_palette.Register("새 탭", "tab new", () => NewTabWithDial());
|
||||
_palette.Register("새 InPrivate 탭", "private incognito 사생활", OpenInPrivateTab);
|
||||
_palette.Register("탭 닫기", "close", () => { if (_tabs.ActiveTab != null) _tabs.CloseTab(_tabs.ActiveTab); });
|
||||
_palette.Register("닫은 탭 복원", "reopen restore", () => _tabs.ReopenClosedTab());
|
||||
_palette.Register("탭 검색", "tab search find", () => ToggleTabSearch(this, new RoutedEventArgs()));
|
||||
_palette.Register("다운로드", "downloads page", () => DownloadsPopup.IsOpen = true);
|
||||
_palette.Register("북마크바 토글", "bookmark bar", ToggleBookmarkBar);
|
||||
_palette.Register("전체화면", "fullscreen f11", ToggleFullscreen);
|
||||
_palette.Register("리더 모드", "reader reading 읽기", async () => await ToggleReader());
|
||||
_palette.Register("강제 다크 모드 토글", "dark boost theme", ToggleBoostDark);
|
||||
_palette.Register("세로 탭 토글", "vertical layout", ToggleVerticalTabs);
|
||||
_palette.Register("분할 화면 토글", "split zen tile", ToggleSplit);
|
||||
_palette.Register("그림 속 그림 (PiP)", "pip picture video", TogglePip);
|
||||
_palette.Register("스크린샷", "screenshot capture", async () => await TakeScreenshot());
|
||||
_palette.Register("소스 보기", "source html", ViewSource);
|
||||
_palette.Register("인쇄", "print pdf", () => { try { Cv?.ShowPrintUI(); } catch { } });
|
||||
_palette.Register("저장 폴더 열기", "folder open output", () => OpenInExplorer(null));
|
||||
_palette.Register("이 페이지 즐겨찾기", "bookmark add star", () => AddBookmark_Click(this, new RoutedEventArgs()));
|
||||
_palette.RegisterSeparator();
|
||||
_palette.RegisterDynamic(() => _tabs.Tabs
|
||||
.Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t)))
|
||||
.ToList());
|
||||
_palette.RegisterDynamic(() => _workspaces
|
||||
.Select(w => new PaletteCommand($"워크스페이스 전환: {w.Name}", "workspace switch", () => SwitchWorkspace(w)))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
private void TogglePalette()
|
||||
{
|
||||
var open = !PalettePopup.IsOpen;
|
||||
PalettePopup.IsOpen = open;
|
||||
if (open) { PaletteBox.Clear(); PaletteBox.Focus(); }
|
||||
}
|
||||
|
||||
private void PaletteBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
var results = _palette.Search(PaletteBox.Text);
|
||||
PaletteResults.ItemsSource = results;
|
||||
PaletteResults.SelectedIndex = results.Count > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
private void PaletteBox_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Escape) { PalettePopup.IsOpen = false; e.Handled = true; }
|
||||
else if (e.Key == Key.Down && PaletteResults.HasItems) { PaletteResults.SelectedIndex = Math.Min(PaletteResults.SelectedIndex + 1, PaletteResults.Items.Count - 1); e.Handled = true; }
|
||||
else if (e.Key == Key.Up && PaletteResults.HasItems) { PaletteResults.SelectedIndex = Math.Max(PaletteResults.SelectedIndex - 1, 0); e.Handled = true; }
|
||||
else if (e.Key == Key.Enter)
|
||||
{
|
||||
if (PaletteResults.SelectedItem is PaletteCommand cmd) { PalettePopup.IsOpen = false; cmd.Execute(); }
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void PaletteResult_Click(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (PaletteResults.SelectedItem is PaletteCommand cmd) { PalettePopup.IsOpen = false; cmd.Execute(); }
|
||||
}
|
||||
|
||||
// ---- T2-4 리더 모드 ----
|
||||
private async Task ToggleReader()
|
||||
{
|
||||
var tab = _tabs.ActiveTab;
|
||||
if (tab == null) return;
|
||||
var result = await ReaderMode.ToggleAsync(tab, App.IsLightTheme);
|
||||
if (result == null) Log("이 페이지에서는 리더 모드를 사용할 수 없습니다");
|
||||
}
|
||||
|
||||
// ---- T2-7 부스트(강제 다크) ----
|
||||
private void ToggleBoostDark()
|
||||
{
|
||||
var url = Cv?.Source;
|
||||
if (string.IsNullOrEmpty(url)) { Log("페이지가 없습니다"); return; }
|
||||
var cur = _boostStore.Get(url);
|
||||
_boostStore.Set(url, !cur.ForceDark, cur.CustomCss);
|
||||
_views?.ApplyBoostNow(_tabs.ActiveTab!);
|
||||
Log(!cur.ForceDark ? "이 사이트 강제 다크 켜짐" : "이 사이트 강제 다크 꺼짐");
|
||||
}
|
||||
|
||||
// ---- T2-9 스크린샷 ----
|
||||
private async Task TakeScreenshot()
|
||||
{
|
||||
var cv = Cv;
|
||||
if (cv == null) return;
|
||||
try
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await cv.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png, ms);
|
||||
var path = MakeUniqueDownloadName(Path.GetFullPath(_config.OutputFolder),
|
||||
$"screenshot_{DateTime.Now:yyyyMMdd_HHmmss}.png");
|
||||
await File.WriteAllBytesAsync(path, ms.ToArray());
|
||||
Log($"스크린샷 저장: {Path.GetFileName(path)}");
|
||||
}
|
||||
catch (Exception ex) { Log($"스크린샷 실패: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// ---- T2-5 세로 탭 ----
|
||||
private void ToggleVerticalTabs()
|
||||
{
|
||||
_verticalTabs = !_verticalTabs;
|
||||
if (_verticalTabs)
|
||||
{
|
||||
TabStripRow.Visibility = Visibility.Collapsed;
|
||||
SideStripCol.Width = new GridLength(210);
|
||||
var sv = new ScrollViewer
|
||||
{
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||
Padding = new Thickness(4, 8, 4, 8)
|
||||
};
|
||||
sv.Content = TabList;
|
||||
TabSideHost.Child = sv;
|
||||
SetTabListOrientation(Orientation.Vertical);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetTabListOrientation(Orientation.Horizontal);
|
||||
TabSideHost.Child = null;
|
||||
// 원래 스트립의 DockPanel 로 복귀 (NewTabBtn 옆 ScrollViewer)
|
||||
var sv = new ScrollViewer
|
||||
{
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||
VerticalAlignment = VerticalAlignment.Bottom
|
||||
};
|
||||
sv.Content = TabList;
|
||||
// 기존 DockPanel: 스트립의 유일한 DockPanel
|
||||
var dock = TabStripRow.Child as DockPanel;
|
||||
dock?.Children.Add(sv);
|
||||
SideStripCol.Width = new GridLength(0);
|
||||
TabStripRow.Visibility = Visibility.Visible;
|
||||
}
|
||||
if (_fullscreen) TabStripRow.Visibility = Visibility.Collapsed; // 전체화면 유지
|
||||
}
|
||||
|
||||
private void SetTabListOrientation(Orientation o)
|
||||
{
|
||||
var factory = new FrameworkElementFactory(typeof(StackPanel));
|
||||
factory.SetValue(StackPanel.OrientationProperty, o);
|
||||
TabList.ItemsPanel = new ItemsPanelTemplate(factory);
|
||||
}
|
||||
|
||||
// ---- T2-3 분할 화면 (활성 + 다음 탭 나란히) ----
|
||||
private void ToggleSplit()
|
||||
{
|
||||
if (_splitTab != null) { ExitSplit(); return; }
|
||||
var active = _tabs.ActiveTab;
|
||||
if (active == null) return;
|
||||
var next = _tabs.Tabs.FirstOrDefault(t => !ReferenceEquals(t, active)) ?? _tabs.NewTab(activate: false);
|
||||
if (next.View?.Parent is Grid host) host.Children.Remove(next.View);
|
||||
_splitTab = next;
|
||||
SplitRightHost.Child = next.View;
|
||||
next.View!.Visibility = Visibility.Visible;
|
||||
SplitCol.Width = new GridLength(Math.Max(280, (TabHost.ActualWidth == 0 ? 600 : TabHost.ActualWidth) / 2));
|
||||
SplitRightHost.Visibility = Visibility.Visible;
|
||||
SplitSplitter.Visibility = Visibility.Visible;
|
||||
Log($"분할 화면: {Trunc(next.Title, 30)}");
|
||||
}
|
||||
|
||||
private void ExitSplit()
|
||||
{
|
||||
if (_splitTab?.View != null)
|
||||
{
|
||||
SplitRightHost.Child = null;
|
||||
TabHost.Children.Add(_splitTab.View);
|
||||
_splitTab.View.Visibility = ReferenceEquals(_splitTab, _tabs.ActiveTab) ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
_splitTab = null;
|
||||
SplitCol.Width = new GridLength(0);
|
||||
SplitRightHost.Visibility = Visibility.Collapsed;
|
||||
SplitSplitter.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
// ---- T2-6 Glance (Alt+클릭 링크 미리보기) ----
|
||||
private void OpenGlance(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var glance = new GlanceWindow(_env!, _config, url)
|
||||
{
|
||||
Owner = this,
|
||||
ShowInTaskbar = false,
|
||||
WindowStyle = WindowStyle.None,
|
||||
AllowsTransparency = false,
|
||||
Background = System.Windows.Media.Brushes.Black
|
||||
};
|
||||
glance.ExpandRequested += g =>
|
||||
{
|
||||
g.Close();
|
||||
_tabs.NewTab(g.Url); // 탭으로 승격
|
||||
};
|
||||
glance.Show();
|
||||
}
|
||||
catch (Exception ex) { Log($"미리보기 실패: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// ---- T3-9 낭독(TTS) ----
|
||||
private void SpeakText(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
_speech?.Dispose();
|
||||
_speech = new System.Speech.Synthesis.SpeechSynthesizer();
|
||||
if (text.Length > 4000) text = text[..4000];
|
||||
_speech.SpeakAsync(text);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ---- T3-3 InPrivate 탭 ----
|
||||
private void OpenInPrivateTab()
|
||||
{
|
||||
_views!.NextTabIsPrivate = true;
|
||||
var t = _tabs.NewTab();
|
||||
t.Title = "InPrivate 탭";
|
||||
Log("InPrivate 탭 열림 — 이 탭은 디스크에 저장되지 않습니다");
|
||||
}
|
||||
|
||||
// ---- T3-10 소스 보기 ----
|
||||
private async void ViewSource()
|
||||
{
|
||||
var cv = Cv;
|
||||
if (cv == null) return;
|
||||
try
|
||||
{
|
||||
var html = await cv.ExecuteScriptAsync("document.documentElement.outerHTML");
|
||||
var decoded = System.Text.Json.JsonSerializer.Deserialize<string>(html) ?? "";
|
||||
var wrapped = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#1b1c24;color:#d4d4dc;font-family:Consolas,monospace;font-size:12.5px;white-space:pre-wrap;padding:20px}</style></head><body>"
|
||||
+ System.Net.WebUtility.HtmlEncode(decoded) + "</body></html>";
|
||||
cv.NavigateToString(wrapped);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ---- T3-7 PiP ----
|
||||
private void TogglePip()
|
||||
{
|
||||
var cv = Cv;
|
||||
if (cv == null) return;
|
||||
_ = cv.ExecuteScriptAsync("""
|
||||
(function(){ try {
|
||||
var v = Array.from(document.querySelectorAll('video')).find(x => !x.paused && !x.disabled) || document.querySelector('video');
|
||||
if (!v) { return 'NO_VIDEO'; }
|
||||
if (document.pictureInPictureElement) { document.exitPictureInPicture(); return 'EXITED'; }
|
||||
v.disablePictureInPicture = false;
|
||||
v.requestPictureInPicture(); return 'PIP';
|
||||
} catch(e){ return 'ERR:'+e.message; } })();
|
||||
""");
|
||||
Log("그림 속 그림 전환 (재생 중인 영상이 없으면 첫 번째 영상)");
|
||||
}
|
||||
|
||||
// ---- T3-6 새 탭 스피드 다이얼 ----
|
||||
private void NewTabWithDial()
|
||||
{
|
||||
var tab = _tabs.NewTab();
|
||||
_ = ShowDialAsync(tab);
|
||||
}
|
||||
|
||||
private async Task ShowDialAsync(TabItem tab)
|
||||
{
|
||||
await WaitTabReadyLocal(tab);
|
||||
var cv = tab.Core;
|
||||
if (cv == null) return;
|
||||
var tiles = _bookmarks.Take(18).Select(b =>
|
||||
$"<a href='{System.Net.WebUtility.HtmlEncode(b.Url)}' style='display:block;text-decoration:none;background:#262736;color:#e6e6ec;border-radius:12px;padding:18px 12px;text-align:center;font-size:12.5px;min-width:110px' onmouseover='this.style.background=#2f3042' onmouseout='this.style.background=#262736'><div style='font-size:26px;margin-bottom:8px'>🌐</div><div style='overflow:hidden;text-overflow:ellipsis;white-space:nowrap'>{System.Net.WebUtility.HtmlEncode(b.Title)}</div></a>").ToList();
|
||||
var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#8a8aa0;font-size:12px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
|
||||
cv.NavigateToString(dial);
|
||||
}
|
||||
|
||||
private async Task WaitTabReadyLocal(TabItem tab)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (tab.Core == null && sw.ElapsedMilliseconds < 15000)
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
// ---- T2-11/T3-1 탭 컨텍스트 메뉴 + 워크스페이스 ----
|
||||
private static readonly string[] GroupColors = { "#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#e91e8c" };
|
||||
|
||||
private void Tab_ContextMenu(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is not TabItem tab) return;
|
||||
e.Handled = true;
|
||||
var menu = new ContextMenu();
|
||||
|
||||
var pin = new MenuItem { Header = tab.IsPinned ? "고정 해제" : "탭 고정" };
|
||||
pin.Click += (_, _) => _tabs.SetPinned(tab, !tab.IsPinned);
|
||||
menu.Items.Add(pin);
|
||||
|
||||
var dup = new MenuItem { Header = "탭 복제" };
|
||||
dup.Click += (_, _) => _tabs.NewTab(tab.Url);
|
||||
menu.Items.Add(dup);
|
||||
|
||||
var mute = new MenuItem { Header = tab.Core is { IsMuted: true } ? "음소거 해제" : "사이트 음소거" };
|
||||
mute.Click += (_, _) => { try { if (tab.Core != null) { tab.Core.IsMuted = !(tab.Core?.IsMuted == true); tab.IsMuted = tab.Core.IsMuted; } } catch { } };
|
||||
menu.Items.Add(mute);
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
|
||||
var group = new MenuItem { Header = "그룹" };
|
||||
var noGroup = new MenuItem { Header = "그룹 없음" };
|
||||
noGroup.Click += (_, _) => _tabs.SetGroup(tab, null, null);
|
||||
group.Items.Add(noGroup);
|
||||
for (var i = 0; i < GroupColors.Length; i++)
|
||||
{
|
||||
var color = GroupColors[i];
|
||||
var mi = new MenuItem { Header = $"그룹 {i + 1}" };
|
||||
var sw = new System.Windows.Shapes.Ellipse { Width = 10, Height = 10 };
|
||||
sw.Fill = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(color));
|
||||
mi.Icon = sw;
|
||||
mi.Click += (_, _) => _tabs.SetGroup(tab, $"G{i + 1}", color);
|
||||
group.Items.Add(mi);
|
||||
}
|
||||
menu.Items.Add(group);
|
||||
|
||||
if (tab.GroupName != null)
|
||||
{
|
||||
var closeG = new MenuItem { Header = $"그룹 전체 닫기 ({tab.GroupName})" };
|
||||
closeG.Click += (_, _) => _tabs.CloseGroup(tab.GroupName!);
|
||||
menu.Items.Add(closeG);
|
||||
}
|
||||
|
||||
menu.Items.Add(new Separator());
|
||||
var close = new MenuItem { Header = "탭 닫기" };
|
||||
close.Click += (_, _) => _tabs.CloseTab(tab);
|
||||
menu.Items.Add(close);
|
||||
|
||||
menu.PlacementTarget = sender as UIElement;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
|
||||
private void SaveWorkspace(string name)
|
||||
{
|
||||
_workspaces.RemoveAll(w => w.Name == name);
|
||||
_workspaces.Insert(0, _tabs.CaptureWorkspace(name));
|
||||
if (_workspaces.Count > 6) _workspaces.RemoveAt(_workspaces.Count - 1);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(AppDataDir);
|
||||
File.WriteAllText(Path.Combine(AppDataDir, "workspaces.json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(_workspaces));
|
||||
}
|
||||
catch { }
|
||||
Log($"워크스페이스 '{name}' 저장 ({_tabs.Tabs.Count} 탭)");
|
||||
}
|
||||
|
||||
private void SwitchWorkspace(WorkspaceSnapshot ws)
|
||||
{
|
||||
ExitSplit();
|
||||
_tabs.SwitchWorkspace(ws);
|
||||
Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)");
|
||||
}
|
||||
|
||||
// ---- 카드 액션 ----
|
||||
private void CardDownload_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue