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
2314
VideoDownloader.App/Assets/readability.js
Normal file
2314
VideoDownloader.App/Assets/readability.js
Normal file
File diff suppressed because it is too large
Load diff
128
VideoDownloader.App/Browser/Features/CommandPalette.cs
Normal file
128
VideoDownloader.App/Browser/Features/CommandPalette.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VideoDownloader.App.Browser.Features;
|
||||||
|
|
||||||
|
/// <summary>팔레트 명령(T2-1, Arc Command Bar / Vivaldi Quick Commands 패턴).</summary>
|
||||||
|
public sealed record PaletteCommand(string Title, string Keywords, Action Execute, string? Category = null);
|
||||||
|
|
||||||
|
/// <summary>명령 등록·검색·실행. 동적 소스(탭/기록/북마크)는 검색 시점에 평가.</summary>
|
||||||
|
public sealed class CommandPalette
|
||||||
|
{
|
||||||
|
private readonly List<PaletteCommand> _static = new();
|
||||||
|
private readonly List<Func<IEnumerable<PaletteCommand>>> _dynamic = new();
|
||||||
|
private readonly List<PaletteCommand> _separators = new(); // 빈질의 표시용 구분자(Title="\u2015")
|
||||||
|
|
||||||
|
public IReadOnlyList<PaletteCommand> Register(string title, string keywords, Action execute, string? category = null)
|
||||||
|
{
|
||||||
|
var cmd = new PaletteCommand(title, keywords, execute, category);
|
||||||
|
_static.Add(cmd);
|
||||||
|
return new[] { cmd };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterDynamic(Func<List<PaletteCommand>> source) => _dynamic.Add(source);
|
||||||
|
|
||||||
|
public void RegisterSeparator() => _separators.Add(new PaletteCommand("\u2015", "", () => { }));
|
||||||
|
|
||||||
|
/// <summary>검색: 정적+동적 통합, 시작-일치 우선, 대소문자 무시. 빈 질의는 정적 전체(등록순).</summary>
|
||||||
|
public List<PaletteCommand> Search(string? query)
|
||||||
|
{
|
||||||
|
query = query?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(query))
|
||||||
|
return _static.ToList();
|
||||||
|
|
||||||
|
var low = query.ToLowerInvariant();
|
||||||
|
var pool = _static.Concat(_dynamic.SelectMany(d => d())).ToList();
|
||||||
|
|
||||||
|
return pool
|
||||||
|
.Select(c => new { Cmd = c, Hay = (c.Title + " " + c.Keywords).ToLowerInvariant() })
|
||||||
|
.Where(x => x.Hay.Contains(low))
|
||||||
|
.OrderByDescending(x => x.Hay.StartsWith(low) ? 1 : 0)
|
||||||
|
.ThenBy(_ => 0) // 안정 정렬 유지(등록순)
|
||||||
|
.Select(x => x.Cmd)
|
||||||
|
.Take(12)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute(PaletteCommand command) => command.Execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>사이트별 부스트 설정(T2-7, Zen/Arc Boosts 패턴).</summary>
|
||||||
|
public sealed record BoostSettings(bool ForceDark, string CustomCss);
|
||||||
|
|
||||||
|
/// <summary>도메인별 부스트 영속 저장.</summary>
|
||||||
|
public sealed class BoostStore
|
||||||
|
{
|
||||||
|
private readonly string _path;
|
||||||
|
private Dictionary<string, BoostSettings> _map = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public BoostStore(string path)
|
||||||
|
{
|
||||||
|
_path = path;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var loaded = JsonSerializer.Deserialize<Dictionary<string, BoostSettings>>(File.ReadAllText(path));
|
||||||
|
if (loaded != null) _map = loaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { _map = new(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public BoostSettings Get(string? url)
|
||||||
|
{
|
||||||
|
var host = Normalize(url);
|
||||||
|
return host != null && _map.TryGetValue(host, out var v) ? v : new BoostSettings(false, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Set(string? url, bool forceDark, string css)
|
||||||
|
{
|
||||||
|
var host = Normalize(url);
|
||||||
|
if (host == null) return;
|
||||||
|
_map[host] = new BoostSettings(forceDark, css);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||||
|
var tmp = _path + ".tmp";
|
||||||
|
File.WriteAllText(tmp, JsonSerializer.Serialize(_map));
|
||||||
|
File.Move(tmp, _path, overwrite: true);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Normalize(string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||||
|
var raw = url.Trim();
|
||||||
|
Uri? uri = null;
|
||||||
|
if (Uri.TryCreate(raw, UriKind.Absolute, out var parsed) && !string.IsNullOrEmpty(parsed.Host)) uri = parsed;
|
||||||
|
else Uri.TryCreate("https://" + raw, UriKind.Absolute, out uri); // 스킴 없는 호스트 허용
|
||||||
|
if (uri == null) return null;
|
||||||
|
var host = uri.Host;
|
||||||
|
return host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>부스트 → 문서 생성 시점 주입 JS 생성(강제 다크: 반전 필터 + 미디어 보호).</summary>
|
||||||
|
public static class BoostCssBuilder
|
||||||
|
{
|
||||||
|
public static string BuildScript(string? url, BoostSettings settings)
|
||||||
|
{
|
||||||
|
if (!settings.ForceDark && string.IsNullOrWhiteSpace(settings.CustomCss)) return "";
|
||||||
|
var css = "";
|
||||||
|
if (settings.ForceDark)
|
||||||
|
{
|
||||||
|
css += """
|
||||||
|
html { filter: invert(1) hue-rotate(180deg); background: #fff; }
|
||||||
|
img, video, picture, canvas, svg, iframe, embed, object { filter: invert(1) hue-rotate(180deg); }
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.CustomCss)) css += settings.CustomCss + "\n";
|
||||||
|
var js = $$"""
|
||||||
|
(function(){ try { var s = document.createElement('style'); s.id = 'vd-boost'; s.textContent = {{JsonSerializer.Serialize(css)}}; (document.head || document.documentElement).appendChild(s); } catch(e){} })();
|
||||||
|
""";
|
||||||
|
return js;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,3 +37,27 @@ public sealed class BoolToClosedConverter : IValueConverter
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>그룹 색상 HEX → Color / 표시 여부 변환 (탭 그룹 바).</summary>
|
||||||
|
public sealed class StringToBrushConverter : System.Windows.Data.IValueConverter
|
||||||
|
{
|
||||||
|
public static readonly StringToBrushConverter Conv = new();
|
||||||
|
public static readonly StringToBrushConverter VisibleIf = new();
|
||||||
|
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||||
|
{
|
||||||
|
var hex = value as string;
|
||||||
|
if (ReferenceEquals(this, VisibleIf))
|
||||||
|
return string.IsNullOrWhiteSpace(hex) ? System.Windows.Visibility.Collapsed : System.Windows.Visibility.Visible;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var c = System.Windows.Media.ColorConverter.ConvertFromString(hex ?? "#5b8def");
|
||||||
|
return c is System.Windows.Media.Color col ? col : System.Windows.Media.Colors.Gray;
|
||||||
|
}
|
||||||
|
catch { return System.Windows.Media.Colors.Gray; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
|
||||||
97
VideoDownloader.App/Browser/Features/ReaderMode.cs
Normal file
97
VideoDownloader.App/Browser/Features/ReaderMode.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
using VideoDownloader.Browser.Tabs;
|
||||||
|
|
||||||
|
namespace VideoDownloader.App.Browser.Features;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 리더 모드(T2-4, Firefox Reader View 패턴): Mozilla Readability.js 로 본문을 추출해
|
||||||
|
/// 스타일된 정적 HTML 로 교체. 원본 URL 은 탭에 보존해 토글 복귀 지원.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReaderMode
|
||||||
|
{
|
||||||
|
private static string? _bundle;
|
||||||
|
|
||||||
|
/// <summary>번들된 Readability.js 소스(어셈블리 리소스, 1회 로드).</summary>
|
||||||
|
public static string Bundle =>
|
||||||
|
_bundle ??= new StreamReader(
|
||||||
|
Assembly.GetExecutingAssembly().GetManifestResourceStream(
|
||||||
|
"VideoDownloader.Assets.readability.js")!).ReadToEnd();
|
||||||
|
|
||||||
|
/// <summary>리더 토글. 반환: 리더로 진입했으면 true, 원본 복귀면 false. 추출 실패 시 null.</summary>
|
||||||
|
public static async Task<bool?> ToggleAsync(TabItem tab, bool lightTheme)
|
||||||
|
{
|
||||||
|
var cv = tab.Core;
|
||||||
|
if (cv == null) return null;
|
||||||
|
|
||||||
|
if (tab.IsReader)
|
||||||
|
{
|
||||||
|
// 원본 복귀
|
||||||
|
tab.IsReader = false;
|
||||||
|
var original = tab.ReaderOriginalUrl;
|
||||||
|
if (!string.IsNullOrEmpty(original))
|
||||||
|
{
|
||||||
|
cv.Navigate(original);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 본문 추출
|
||||||
|
var extractJs =
|
||||||
|
Bundle + "\n" +
|
||||||
|
"""
|
||||||
|
(function(){ try {
|
||||||
|
var docClone = document.cloneNode(true);
|
||||||
|
var article = new Readability(docClone).parse();
|
||||||
|
return JSON.stringify(article || null);
|
||||||
|
} catch(e){ return null; } })();
|
||||||
|
""";
|
||||||
|
string? json = null;
|
||||||
|
try { json = await cv.ExecuteScriptAsync(extractJs); }
|
||||||
|
catch { }
|
||||||
|
if (string.IsNullOrEmpty(json) || json == "null" || json == "\"null\"") return null;
|
||||||
|
|
||||||
|
// ExecuteScriptAsync 는 결과를 JSON 문자열로 한 겹 감싼다 — 언랩 후 파싱
|
||||||
|
var wrapped = System.Text.Json.JsonSerializer.Deserialize<string>(json);
|
||||||
|
if (string.IsNullOrEmpty(wrapped) || wrapped == "null") return null;
|
||||||
|
var article = System.Text.Json.JsonDocument.Parse(wrapped).RootElement;
|
||||||
|
if (!article.TryGetProperty("content", out var contentEl) ||
|
||||||
|
string.IsNullOrWhiteSpace(contentEl.GetString())) return null;
|
||||||
|
|
||||||
|
var title = article.TryGetProperty("title", out var t) ? t.GetString() : "";
|
||||||
|
var excerpt = article.TryGetProperty("excerpt", out var e) ? e.GetString() : "";
|
||||||
|
var byline = article.TryGetProperty("byline", out var b) ? b.GetString() : "";
|
||||||
|
|
||||||
|
// 진입 정보 저장 (복귀용)
|
||||||
|
tab.ReaderOriginalUrl = cv.Source;
|
||||||
|
tab.IsReader = true;
|
||||||
|
tab.Title = string.IsNullOrWhiteSpace(title) ? tab.Title : title + " (리더)";
|
||||||
|
|
||||||
|
cv.NavigateToString(BuildReaderHtml(title ?? "", byline ?? "", excerpt ?? "", contentEl.GetString() ?? "", lightTheme));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>리더 HTML(Firefox 스타일 — 폰트/여백/테마). trusted-types 회피를 위해 iframe srcdoc 형태 대신 직접 문서.</summary>
|
||||||
|
public static string BuildReaderHtml(string title, string byline, string excerpt, string contentHtml, bool lightTheme)
|
||||||
|
{
|
||||||
|
var bg = lightTheme ? "#faf8f5" : "#1b1c24";
|
||||||
|
var fg = lightTheme ? "#1b1c24" : "#e6e6ec";
|
||||||
|
var muted = lightTheme ? "#666" : "#9a9ab0";
|
||||||
|
var css = $@"
|
||||||
|
body {{ background: {bg}; color: {fg}; font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; max-width: 720px; margin: 0 auto; padding: 40px 24px 80px; line-height: 1.75; }}
|
||||||
|
h1 {{ font-size: 1.7em; line-height: 1.3; margin-bottom: 6px; }}
|
||||||
|
.byline {{ color: {muted}; font-size: 0.9em; margin-bottom: 4px; }}
|
||||||
|
.excerpt {{ color: {muted}; font-style: italic; margin: 14px 0 26px; padding-left: 12px; border-left: 3px solid {muted}; }}
|
||||||
|
img, video {{ max-width: 100%; height: auto; }}
|
||||||
|
a {{ color: #5b8def; }}
|
||||||
|
pre {{ overflow-x: auto; background: {(lightTheme ? "#f0eee9" : "#262736")}; padding: 12px; border-radius: 8px; }}
|
||||||
|
blockquote {{ border-left: 3px solid {muted}; margin-left: 0; padding-left: 14px; color: {muted}; }}
|
||||||
|
";
|
||||||
|
var titleEsc = System.Net.WebUtility.HtmlEncode(title);
|
||||||
|
var bylineEsc = System.Net.WebUtility.HtmlEncode(byline);
|
||||||
|
var excerptEsc = System.Net.WebUtility.HtmlEncode(excerpt);
|
||||||
|
return $"""<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{titleEsc}</title><style>{css}</style></head><body><h1>{titleEsc}</h1>{(bylineEsc.Length > 0 ? $"<div class=\"byline\">{bylineEsc}</div>" : "")}{(excerptEsc.Length > 0 ? $"<p class=\"excerpt\">{excerptEsc}</p>" : "")}{contentHtml}</body></html>""";
|
||||||
|
}
|
||||||
|
}
|
||||||
66
VideoDownloader.App/Browser/GlanceWindow.xaml.cs
Normal file
66
VideoDownloader.App/Browser/GlanceWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
using VideoDownloader.Core.Config;
|
||||||
|
using Wv2 = Microsoft.Web.WebView2.Wpf;
|
||||||
|
|
||||||
|
namespace VideoDownloader.App.Browser;
|
||||||
|
|
||||||
|
/// <summary>T2-6 Glance: Alt+클릭 링크의 플로팅 미리보기(Zen Glance 패턴) — 닫기/탭으로 승격.</summary>
|
||||||
|
public partial class GlanceWindow : Window
|
||||||
|
{
|
||||||
|
private readonly Wv2.WebView2 _web = new() { DefaultBackgroundColor = System.Drawing.Color.Black };
|
||||||
|
private readonly CoreWebView2Environment _env;
|
||||||
|
private readonly AppConfig _config;
|
||||||
|
|
||||||
|
public string Url { get; }
|
||||||
|
public event Action<GlanceWindow>? ExpandRequested;
|
||||||
|
|
||||||
|
public GlanceWindow(CoreWebView2Environment env, AppConfig config, string url)
|
||||||
|
{
|
||||||
|
_env = env;
|
||||||
|
_config = config;
|
||||||
|
Url = url;
|
||||||
|
|
||||||
|
Width = 720; Height = 520;
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||||
|
ResizeMode = ResizeMode.CanResize;
|
||||||
|
|
||||||
|
var bar = new Border
|
||||||
|
{
|
||||||
|
Background = System.Windows.Media.Brushes.Black,
|
||||||
|
Padding = new Thickness(8, 6, 8, 6)
|
||||||
|
};
|
||||||
|
var dock = new DockPanel();
|
||||||
|
var close = new Button { Content = "✕ 닫기", Padding = new Thickness(10, 3, 10, 3), Margin = new Thickness(4, 0, 4, 0), Background = System.Windows.Media.Brushes.Transparent, Foreground = System.Windows.Media.Brushes.White, BorderBrush = System.Windows.Media.Brushes.Gray };
|
||||||
|
close.Click += (_, _) => Close();
|
||||||
|
var expand = new Button { Content = "⤢ 탭으로 열기", Padding = new Thickness(10, 3, 10, 3), Background = System.Windows.Media.Brushes.Transparent, Foreground = System.Windows.Media.Brushes.White, BorderBrush = System.Windows.Media.Brushes.Gray };
|
||||||
|
expand.Click += (_, _) => ExpandRequested?.Invoke(this);
|
||||||
|
var title = new TextBlock { Text = url, Foreground = System.Windows.Media.Brushes.LightGray, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
|
||||||
|
DockPanel.SetDock(close, Dock.Right);
|
||||||
|
DockPanel.SetDock(expand, Dock.Right);
|
||||||
|
dock.Children.Add(close);
|
||||||
|
dock.Children.Add(expand);
|
||||||
|
dock.Children.Add(title);
|
||||||
|
bar.Child = dock;
|
||||||
|
|
||||||
|
var grid = new Grid { RowDefinitions = { new RowDefinition { Height = GridLength.Auto }, new RowDefinition { Height = new GridLength(1, GridUnitType.Star) } } };
|
||||||
|
Grid.SetRow(bar, 0); Grid.SetRow(_web, 1);
|
||||||
|
grid.Children.Add(bar);
|
||||||
|
grid.Children.Add(_web);
|
||||||
|
Content = grid;
|
||||||
|
|
||||||
|
Loaded += async (_, _) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = _env.CreateCoreWebView2ControllerOptions();
|
||||||
|
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true;
|
||||||
|
await _web.EnsureCoreWebView2Async(_env, options);
|
||||||
|
_web.CoreWebView2!.Navigate(url);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -262,8 +262,15 @@
|
||||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="1,1,1,0"
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="1,1,1,0"
|
||||||
Padding="10,7,4,7" MouseLeftButtonUp="Tab_Click"
|
Padding="10,7,4,7" MouseLeftButtonUp="Tab_Click"
|
||||||
PreviewMouseDown="Tab_PreviewMouseDown"
|
PreviewMouseDown="Tab_PreviewMouseDown"
|
||||||
|
MouseRightButtonUp="Tab_ContextMenu"
|
||||||
ToolTip="{Binding Url}">
|
ToolTip="{Binding Url}">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
|
<Border DockPanel.Dock="Left" Width="3" CornerRadius="2" Margin="0,0,7,0"
|
||||||
|
Visibility="{Binding GroupColor, Converter={x:Static feat:StringToBrushConverter.VisibleIf}}">
|
||||||
|
<Border.Background>
|
||||||
|
<SolidColorBrush Color="{Binding GroupColor, Converter={x:Static feat:StringToBrushConverter.Conv}}"/>
|
||||||
|
</Border.Background>
|
||||||
|
</Border>
|
||||||
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}"
|
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}"
|
||||||
Content="" FontSize="9" Padding="5,3" Margin="4,0,0,0"
|
Content="" FontSize="9" Padding="5,3" Margin="4,0,0,0"
|
||||||
Click="TabClose_Click" ToolTip="탭 닫기 (Ctrl+W)"
|
Click="TabClose_Click" ToolTip="탭 닫기 (Ctrl+W)"
|
||||||
|
|
@ -302,12 +309,20 @@
|
||||||
<!-- ===== 본문 ===== -->
|
<!-- ===== 본문 ===== -->
|
||||||
<Grid Grid.Row="3">
|
<Grid Grid.Row="3">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition x:Name="SideStripCol" Width="0"/>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition x:Name="SplitCol" Width="0"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition x:Name="RightCol" Width="420" MinWidth="56" MaxWidth="900"/>
|
<ColumnDefinition x:Name="RightCol" Width="420" MinWidth="56" MaxWidth="900"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<Grid Grid.Column="0">
|
<!-- T2-5 세로 탭 사이드 호스트 -->
|
||||||
|
<Border x:Name="TabSideHost" Grid.Column="0" Background="{StaticResource PanelBg}"
|
||||||
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,1,0"
|
||||||
|
ClipToBounds="True" MinWidth="170" MaxWidth="330"/>
|
||||||
|
|
||||||
|
<Grid Grid.Column="1">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
|
@ -348,7 +363,15 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- 리사이즈 핸들 (펼침 상태에서만 노출). 드래그로 우측 패널 폭 변경. -->
|
<!-- 리사이즈 핸들 (펼침 상태에서만 노출). 드래그로 우측 패널 폭 변경. -->
|
||||||
<GridSplitter x:Name="RightSplitter" Grid.Column="1" Width="7"
|
<!-- T2-3 분할 화면: 드래그 스플리터 + 우측 탭 호스트 -->
|
||||||
|
<GridSplitter x:Name="SplitSplitter" Grid.Column="2" Width="7"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Stretch"
|
||||||
|
ResizeBehavior="PreviousAndNext" ResizeDirection="Columns"
|
||||||
|
Background="Transparent" Cursor="SizeWE" Visibility="Collapsed"/>
|
||||||
|
<Border x:Name="SplitRightHost" Grid.Column="3" ClipToBounds="True" Background="Black"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
|
|
||||||
|
<GridSplitter x:Name="RightSplitter" Grid.Column="4" Width="7"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Stretch"
|
HorizontalAlignment="Center" VerticalAlignment="Stretch"
|
||||||
ResizeBehavior="PreviousAndNext" ResizeDirection="Columns"
|
ResizeBehavior="PreviousAndNext" ResizeDirection="Columns"
|
||||||
Background="Transparent" Cursor="SizeWE"
|
Background="Transparent" Cursor="SizeWE"
|
||||||
|
|
@ -368,7 +391,7 @@
|
||||||
</GridSplitter>
|
</GridSplitter>
|
||||||
|
|
||||||
<!-- 범용 우측 패널: 헤더(토글 버튼, 항상 노출) + 본문(펼침 시). 접기/펼치기·폭·상태 저장. -->
|
<!-- 범용 우측 패널: 헤더(토글 버튼, 항상 노출) + 본문(펼침 시). 접기/펼치기·폭·상태 저장. -->
|
||||||
<Border Grid.Column="2" Background="{StaticResource PanelBg}"
|
<Border Grid.Column="5" Background="{StaticResource PanelBg}"
|
||||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="1,0,0,0">
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="1,0,0,0">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
|
|
@ -583,6 +606,33 @@
|
||||||
</Border>
|
</Border>
|
||||||
</Popup>
|
</Popup>
|
||||||
|
|
||||||
|
<!-- ===== 명령 팔레트 (T2-1, Ctrl+K) ===== -->
|
||||||
|
<Popup Name="PalettePopup" PlacementTarget="{Binding ElementName=AddressBox}"
|
||||||
|
Placement="Center" StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade"
|
||||||
|
Width="560">
|
||||||
|
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="12" Padding="10">
|
||||||
|
<StackPanel>
|
||||||
|
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="8" Margin="2,0,2,8">
|
||||||
|
<TextBox Name="PaletteBox" Style="{StaticResource DarkTextBox}" BorderThickness="0"
|
||||||
|
Background="Transparent" Padding="12,10" FontSize="13.5"
|
||||||
|
TextChanged="PaletteBox_TextChanged" PreviewKeyDown="PaletteBox_PreviewKeyDown"/>
|
||||||
|
</Border>
|
||||||
|
<ListBox Name="PaletteResults" Style="{StaticResource SuggestList}"
|
||||||
|
ItemContainerStyle="{StaticResource SuggestItem}" MaxHeight="360"
|
||||||
|
PreviewMouseLeftButtonUp="PaletteResult_Click">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis"
|
||||||
|
FontSize="12.5" Padding="4,2"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
<!-- ===== 링크로 받기 플라이아웃 (YouTube/Instagram/X URL 붙여넣기) ===== -->
|
<!-- ===== 링크로 받기 플라이아웃 (YouTube/Instagram/X URL 붙여넣기) ===== -->
|
||||||
<Popup Name="LinkPopup" PlacementTarget="{Binding ElementName=LinkBtn}"
|
<Popup Name="LinkPopup" PlacementTarget="{Binding ElementName=LinkBtn}"
|
||||||
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
|
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ using System.Windows.Interop;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using Microsoft.Web.WebView2.Core;
|
using Microsoft.Web.WebView2.Core;
|
||||||
using VideoDownloader.Browser;
|
using VideoDownloader.Browser;
|
||||||
|
using VideoDownloader.App.Browser;
|
||||||
using VideoDownloader.App.Browser.Features;
|
using VideoDownloader.App.Browser.Features;
|
||||||
using VideoDownloader.Browser.Tabs;
|
using VideoDownloader.Browser.Tabs;
|
||||||
using VideoDownloader.App.Core;
|
using VideoDownloader.App.Core;
|
||||||
|
|
@ -39,6 +40,14 @@ public partial class MainWindow : Window
|
||||||
private readonly ObservableCollection<HistoryEntry> _history = new();
|
private readonly ObservableCollection<HistoryEntry> _history = new();
|
||||||
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
|
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)
|
// Phase B: 다운로드 매니저(T1-5) / 탭 절전(T2-2)
|
||||||
private readonly PageDownloadTracker _pageDownloads = new();
|
private readonly PageDownloadTracker _pageDownloads = new();
|
||||||
private System.Windows.Threading.DispatcherTimer? _speedTimer;
|
private System.Windows.Threading.DispatcherTimer? _speedTimer;
|
||||||
|
|
@ -84,6 +93,15 @@ public partial class MainWindow : Window
|
||||||
|
|
||||||
_resolver = new SearchEngineResolver(_config.SearchEngine);
|
_resolver = new SearchEngineResolver(_config.SearchEngine);
|
||||||
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
|
_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();
|
Loaded += async (_, _) => await InitializeBrowserAsync();
|
||||||
PreviewKeyDown += OnPreviewKeyDown;
|
PreviewKeyDown += OnPreviewKeyDown;
|
||||||
|
|
@ -184,8 +202,14 @@ public partial class MainWindow : Window
|
||||||
};
|
};
|
||||||
|
|
||||||
_views.ContextMenuBuild += OnContextMenuBuild;
|
_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.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 = new TabManager(() => new TabItem(), _views.AttachView);
|
||||||
_tabs.ActiveChanged += OnActiveTabChanged;
|
_tabs.ActiveChanged += OnActiveTabChanged;
|
||||||
|
|
@ -299,6 +323,7 @@ public partial class MainWindow : Window
|
||||||
|
|
||||||
private void OnActiveTabChanged(TabItem tab)
|
private void OnActiveTabChanged(TabItem tab)
|
||||||
{
|
{
|
||||||
|
if (_splitTab != null && !ReferenceEquals(tab, _splitTab)) ExitSplit(); // v1: 전환 시 분할 해제
|
||||||
foreach (var t in _tabs.Tabs)
|
foreach (var t in _tabs.Tabs)
|
||||||
if (t.View != null) t.View.Visibility = ReferenceEquals(t, tab) ? Visibility.Visible : Visibility.Collapsed;
|
if (t.View != null) t.View.Visibility = ReferenceEquals(t, tab) ? Visibility.Visible : Visibility.Collapsed;
|
||||||
if (!AddressBox.IsKeyboardFocused) AddressBox.Text = tab.Url;
|
if (!AddressBox.IsKeyboardFocused) AddressBox.Text = tab.Url;
|
||||||
|
|
@ -310,7 +335,11 @@ public partial class MainWindow : Window
|
||||||
UpdateNavState();
|
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()
|
private void UpdateNavState()
|
||||||
{
|
{
|
||||||
|
|
@ -323,7 +352,7 @@ public partial class MainWindow : Window
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 탭 스트립 UI 이벤트 ----
|
// ---- 탭 스트립 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)
|
private void Tab_Click(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
|
|
@ -645,8 +674,18 @@ public partial class MainWindow : Window
|
||||||
var mods = Keyboard.Modifiers;
|
var mods = Keyboard.Modifiers;
|
||||||
|
|
||||||
// Phase A 탭 단축키
|
// 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.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.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.B) { ToggleBookmarkBar(); 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.T) { _tabs.ReopenClosedTab(); e.Handled = true; }
|
||||||
|
|
@ -1079,6 +1118,363 @@ public partial class MainWindow : Window
|
||||||
_manager.OnDetected(new DetectionContext(link, referer, kind, ""));
|
_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)
|
private void CardDownload_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,18 @@ public sealed class TabViewService
|
||||||
/// <summary>우클릭 메뉴 요청 — 호스트가 WPF ContextMenu 로 재구성해 표시(Handled=true).</summary>
|
/// <summary>우클릭 메뉴 요청 — 호스트가 WPF ContextMenu 로 재구성해 표시(Handled=true).</summary>
|
||||||
public event Action<CoreWebView2, CoreWebView2ContextMenuRequestedEventArgs>? ContextMenuBuild;
|
public event Action<CoreWebView2, CoreWebView2ContextMenuRequestedEventArgs>? ContextMenuBuild;
|
||||||
|
|
||||||
|
/// <summary>T2-7 부스트: URL → 주입할 JS(강제 다크/커스텀 CSS). 빈 문자열이면 미적용.</summary>
|
||||||
|
public Func<string?, string>? BoostScriptProvider { get; set; }
|
||||||
|
|
||||||
|
/// <summary>T2-6 Glance: Alt+클릭한 링크 URL 요청.</summary>
|
||||||
|
public event Action<CoreWebView2, string>? GlanceRequested;
|
||||||
|
|
||||||
|
/// <summary>T2-4 리더 내 낭독(TTS) 요청 — 리더 본문 텍스트.</summary>
|
||||||
|
public event Action<CoreWebView2, string>? SpeakRequested;
|
||||||
|
|
||||||
|
/// <summary>T3-3 InPrivate 탭: 다음에 만들어지는 탭에 적용(1회성).</summary>
|
||||||
|
public bool NextTabIsPrivate { get; set; }
|
||||||
|
|
||||||
public TabViewService(CoreWebView2Environment env, AppConfig config, Panel host,
|
public TabViewService(CoreWebView2Environment env, AppConfig config, Panel host,
|
||||||
SiteZoomStore zoomStore, Dispatcher dispatcher)
|
SiteZoomStore zoomStore, Dispatcher dispatcher)
|
||||||
{
|
{
|
||||||
|
|
@ -68,6 +80,16 @@ public sealed class TabViewService
|
||||||
_init[tab] = InitCoreAsync(tab);
|
_init[tab] = InitCoreAsync(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>부스트 즉시 재적용(토글 직후). 비동기 fire-and-forget.</summary>
|
||||||
|
public void ApplyBoostNow(TabItem tab)
|
||||||
|
{
|
||||||
|
var cv = tab.Core;
|
||||||
|
if (cv == null) return;
|
||||||
|
var js = BoostScriptProvider?.Invoke(cv.Source) ?? "";
|
||||||
|
if (js.Length > 0 && !tab.IsReader) _ = cv.ExecuteScriptAsync(js);
|
||||||
|
else if (js.Length == 0) _ = cv.ExecuteScriptAsync("(function(){try{document.getElementById('vd-boost')?.remove();}catch(e){}})();");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용).</summary>
|
/// <summary>탭의 CoreWebView2 초기화 완료 대기 (뉴윈도우 deferral 용).</summary>
|
||||||
public async Task WaitTabReady(TabItem tab)
|
public async Task WaitTabReady(TabItem tab)
|
||||||
{
|
{
|
||||||
|
|
@ -94,7 +116,9 @@ public sealed class TabViewService
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var options = _env.CreateCoreWebView2ControllerOptions();
|
var options = _env.CreateCoreWebView2ControllerOptions();
|
||||||
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
|
if (_config.PrivateMode || tab.IsPrivate || NextTabIsPrivate) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
|
||||||
|
if (!tab.IsPrivate && NextTabIsPrivate) tab.IsPrivate = true;
|
||||||
|
NextTabIsPrivate = false;
|
||||||
await tab.View!.EnsureCoreWebView2Async(_env, options);
|
await tab.View!.EnsureCoreWebView2Async(_env, options);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
|
|
@ -105,6 +129,35 @@ public sealed class TabViewService
|
||||||
var cv = tab.Core!;
|
var cv = tab.Core!;
|
||||||
cv.Settings.AreDevToolsEnabled = true;
|
cv.Settings.AreDevToolsEnabled = true;
|
||||||
|
|
||||||
|
// T2-6 Glance: Alt+클릭 → 링크를 postMessage 로 전달 (캡처 단계에서 preventDefault)
|
||||||
|
const string glanceScript = """
|
||||||
|
(function(){ try { document.addEventListener('click', function(e){
|
||||||
|
if (e.altKey) { var a = e.target && e.target.closest ? e.target.closest('a') : null;
|
||||||
|
if (a && a.href) { e.preventDefault(); e.stopPropagation(); window.chrome.webview.postMessage({ vdGlance: a.href }); } }
|
||||||
|
}, true); } catch(err){} })();
|
||||||
|
""";
|
||||||
|
try { await cv.AddScriptToExecuteOnDocumentCreatedAsync(glanceScript); } catch { }
|
||||||
|
|
||||||
|
// WebMessage 라우팅 (Glance / 리더 낭독)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cv.WebMessageReceived += (_, we) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var msg = we.WebMessageAsJson;
|
||||||
|
if (string.IsNullOrEmpty(msg)) return;
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(msg);
|
||||||
|
if (doc.RootElement.TryGetProperty("vdGlance", out var g) && g.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||||
|
GlanceRequested?.Invoke(cv, g.GetString() ?? "");
|
||||||
|
if (doc.RootElement.TryGetProperty("vdSpeak", out var sp) && sp.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||||
|
SpeakRequested?.Invoke(cv, sp.GetString() ?? "");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
// 탭별 검색 세션 (네이티브 Find API)
|
// 탭별 검색 세션 (네이티브 Find API)
|
||||||
tab.FindSession = new FindSession(new WebView2FindApi(cv, _env));
|
tab.FindSession = new FindSession(new WebView2FindApi(cv, _env));
|
||||||
tab.FindSession.StateChanged += () => _dispatcher.BeginInvoke(new Action(() =>
|
tab.FindSession.StateChanged += () => _dispatcher.BeginInvoke(new Action(() =>
|
||||||
|
|
@ -118,6 +171,12 @@ public sealed class TabViewService
|
||||||
{
|
{
|
||||||
tab.IsLoading = false;
|
tab.IsLoading = false;
|
||||||
try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7 사이트별 줌
|
try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7 사이트별 줌
|
||||||
|
try // T2-7 부스트(강제 다크/커스텀 CSS) 적용
|
||||||
|
{
|
||||||
|
var boostJs = BoostScriptProvider?.Invoke(cv.Source) ?? "";
|
||||||
|
if (boostJs.Length > 0 && !tab.IsReader) _ = cv.ExecuteScriptAsync(boostJs);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
}));
|
}));
|
||||||
cv.HistoryChanged += (_, _) => HistoryChanged?.Invoke();
|
cv.HistoryChanged += (_, _) => HistoryChanged?.Invoke();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,16 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
|
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
|
||||||
|
<PackageReference Include="System.Speech" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- T2-4 리더 모드: Mozilla Readability.js 번들 (MIT) -->
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Assets/readability.js" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- E2E 테스트(InternalsVisibleTo) -->
|
<!-- E2E 테스트(InternalsVisibleTo) -->
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ public sealed class TabItem : INotifyPropertyChanged
|
||||||
private bool _isLoading;
|
private bool _isLoading;
|
||||||
public bool IsLoading { get => _isLoading; set => Set(ref _isLoading, value); }
|
public bool IsLoading { get => _isLoading; set => Set(ref _isLoading, value); }
|
||||||
|
|
||||||
|
private string? _groupName; // T2-11 탭 그룹
|
||||||
|
public string? GroupName { get => _groupName; set => Set(ref _groupName, value); }
|
||||||
|
|
||||||
|
private string? _groupColor;
|
||||||
|
public string? GroupColor { get => _groupColor; set => Set(ref _groupColor, value); }
|
||||||
|
|
||||||
/// <summary>마지막 활성 시각(탭 절전 판정용)</summary>
|
/// <summary>마지막 활성 시각(탭 절전 판정용)</summary>
|
||||||
public DateTime LastActivated { get; set; } = DateTime.UtcNow;
|
public DateTime LastActivated { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
|
@ -52,6 +58,13 @@ public sealed class TabItem : INotifyPropertyChanged
|
||||||
/// <summary>탭별 페이지 내 검색 세션 (CoreWebView2 바인딩).</summary>
|
/// <summary>탭별 페이지 내 검색 세션 (CoreWebView2 바인딩).</summary>
|
||||||
public FindSession? FindSession { get; set; }
|
public FindSession? FindSession { get; set; }
|
||||||
|
|
||||||
|
/// <summary>T2-4 리더 모드 진입 여부.</summary>
|
||||||
|
public bool IsReader { get; set; }
|
||||||
|
/// <summary>T3-3 InPrivate(사생활 보호) 탭 여부.</summary>
|
||||||
|
public bool IsPrivate { get; set; }
|
||||||
|
/// <summary>리더 진입 전 원본 URL(복귀용).</summary>
|
||||||
|
public string? ReaderOriginalUrl { get; set; }
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||||
|
|
|
||||||
|
|
@ -162,3 +162,44 @@ public sealed class TabManager
|
||||||
ActivateAt(Math.Clamp(snap.ActiveIndex, 0, Math.Max(0, Tabs.Count - 1)));
|
ActivateAt(Math.Clamp(snap.ActiveIndex, 0, Math.Max(0, Tabs.Count - 1)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── T2-11 탭 그룹 확장 ──
|
||||||
|
public static class TabManagerGroupExtensions
|
||||||
|
{
|
||||||
|
/// <summary>그룹 지정(null 이면 해제). 같은 그룹 탭은 인접하도록 이동.</summary>
|
||||||
|
public static void SetGroup(this TabManager m, TabItem tab, string? name, string? color)
|
||||||
|
{
|
||||||
|
if (!m.Tabs.Contains(tab)) return;
|
||||||
|
tab.GroupName = name;
|
||||||
|
tab.GroupColor = color;
|
||||||
|
if (name == null) return;
|
||||||
|
// 같은 그룹 마지막 탭 뒤로 이동
|
||||||
|
var lastIdx = -1;
|
||||||
|
for (var i = 0; i < m.Tabs.Count; i++)
|
||||||
|
if (string.Equals(m.Tabs[i].GroupName, name, StringComparison.Ordinal) && !ReferenceEquals(m.Tabs[i], tab))
|
||||||
|
lastIdx = i;
|
||||||
|
if (lastIdx >= 0) m.Move(tab, lastIdx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>그룹 전체 닫기. 반환: 닫은 탭 수.</summary>
|
||||||
|
public static int CloseGroup(this TabManager m, string groupName)
|
||||||
|
{
|
||||||
|
var group = m.Tabs.Where(t => string.Equals(t.GroupName, groupName, StringComparison.Ordinal)).ToList();
|
||||||
|
foreach (var t in group) m.CloseTab(t);
|
||||||
|
return group.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>워크스페이스 스냅샷(T3-1, Zen Workspaces 패턴).</summary>
|
||||||
|
public sealed record WorkspaceSnapshot(string Name, List<string> Urls);
|
||||||
|
|
||||||
|
public static class TabManagerWorkspaceExtensions
|
||||||
|
{
|
||||||
|
/// <summary>현재 탭 세트를 워크스페이스로 캡처(현재 상태는 유지).</summary>
|
||||||
|
public static WorkspaceSnapshot CaptureWorkspace(this TabManager m, string name)
|
||||||
|
=> new(name, m.Tabs.Select(t => t.Url).ToList());
|
||||||
|
|
||||||
|
/// <summary>워크스페이스로 전환: 기존 탭 제거 후 스냅샷 복원.</summary>
|
||||||
|
public static void SwitchWorkspace(this TabManager m, WorkspaceSnapshot ws)
|
||||||
|
=> m.RestoreSession(new SessionSnapshot(ws.Urls, 0, Enumerable.Repeat(false, ws.Urls.Count).ToList()));
|
||||||
|
}
|
||||||
|
|
|
||||||
128
VideoDownloader.Tests/Browser/CommandPaletteTests.cs
Normal file
128
VideoDownloader.Tests/Browser/CommandPaletteTests.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
using VideoDownloader.App.Browser.Features;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Tests.Browser;
|
||||||
|
|
||||||
|
// ── T2-1 명령 팔레트: 등록·검색·실행 ──
|
||||||
|
public class CommandPaletteTests
|
||||||
|
{
|
||||||
|
private sealed class CommandHost
|
||||||
|
{
|
||||||
|
public List<string> Executed = new();
|
||||||
|
public List<string> Tabs = new() { "https://a.com", "https://b.com" };
|
||||||
|
public List<string> History = new() { "https://news.com" };
|
||||||
|
|
||||||
|
public CommandPalette Build()
|
||||||
|
{
|
||||||
|
var palette = new CommandPalette();
|
||||||
|
palette.Register("새 탭", "tab new open", () => Executed.Add("newtab"));
|
||||||
|
palette.Register("탭 닫기", "close tab", () => Executed.Add("close"));
|
||||||
|
palette.Register("전체화면", "fullscreen f11", () => Executed.Add("fs"));
|
||||||
|
palette.Register("리더 모드", "reader reading", () => Executed.Add("reader"));
|
||||||
|
palette.Register("다크 모드", "dark theme boost", () => Executed.Add("dark"));
|
||||||
|
palette.RegisterSeparator();
|
||||||
|
// 동적 소스: 탭/히스토리 → 명령으로
|
||||||
|
palette.RegisterDynamic(() => Tabs.Select(t => new PaletteCommand($"탭: {t}", "tab go", () => Executed.Add("go:" + t))).ToList());
|
||||||
|
palette.RegisterDynamic(() => History.Select(h => new PaletteCommand($"기록: {h}", "history", () => Executed.Add("hist:" + h))).ToList());
|
||||||
|
return palette;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 등록된_명령을_검색한다_라벨_키워드_부분일치()
|
||||||
|
{
|
||||||
|
var p = new CommandHost().Build();
|
||||||
|
var hits = p.Search("리더");
|
||||||
|
Assert.Single(hits);
|
||||||
|
Assert.Equal("리더 모드", hits[0].Title);
|
||||||
|
|
||||||
|
hits = p.Search("F11"); // 키워드 검색
|
||||||
|
Assert.Single(hits);
|
||||||
|
|
||||||
|
hits = p.Search("TAB"); // 대소문자 무시
|
||||||
|
Assert.Contains(hits, h => h.Title == "새 탭");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 동적_소스_탭과_기록도_결과에_포함된다()
|
||||||
|
{
|
||||||
|
var p = new CommandHost().Build();
|
||||||
|
var hits = p.Search("탭:");
|
||||||
|
Assert.Equal(2, hits.Count);
|
||||||
|
hits = p.Search("기록:");
|
||||||
|
Assert.Single(hits);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 실행은_콜백을_호출한다()
|
||||||
|
{
|
||||||
|
var host = new CommandHost();
|
||||||
|
var p = host.Build();
|
||||||
|
p.Execute(p.Search("전체").Single());
|
||||||
|
Assert.Equal(new[] { "fs" }, host.Executed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 빈_질의는_정적_명령_전체를_순서대로_반환()
|
||||||
|
{
|
||||||
|
var p = new CommandHost().Build();
|
||||||
|
var hits = p.Search("");
|
||||||
|
Assert.Equal("새 탭", hits[0].Title);
|
||||||
|
Assert.Equal("다크 모드", hits[^1].Title); // 동적 제외, 정적만
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 매치_순위는_시작_일치_우선()
|
||||||
|
{
|
||||||
|
var p = new CommandPalette();
|
||||||
|
p.Register("탭 닫기", "close", () => { });
|
||||||
|
p.Register("닫힌 탭 복원", "reopen closed", () => { });
|
||||||
|
var hits = p.Search("탭");
|
||||||
|
Assert.Equal("탭 닫기", hits[0].Title); // "탭"으로 시작하는 것이 우선
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T2-7 Boost: 사이트별 강제 다크/커스텀 CSS 영속 ──
|
||||||
|
public class BoostStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _path = Path.Combine(Path.GetTempPath(), $"boost_{Guid.NewGuid():N}.json");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 기본은_빈_부스트()
|
||||||
|
{
|
||||||
|
var s = new BoostStore(_path);
|
||||||
|
Assert.False(s.Get("example.com").ForceDark);
|
||||||
|
Assert.Equal("", s.Get("example.com").CustomCss);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 강제다크와_CSS를_저장하면_영속된다()
|
||||||
|
{
|
||||||
|
var s = new BoostStore(_path);
|
||||||
|
s.Set("example.com", forceDark: true, css: "body{zoom:1.1}");
|
||||||
|
var re = new BoostStore(_path);
|
||||||
|
Assert.True(re.Get("example.com").ForceDark);
|
||||||
|
Assert.Equal("body{zoom:1.1}", re.Get("example.com").CustomCss);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void www_정규화_및_빈_부스트는_CSS를_만들지_않는다()
|
||||||
|
{
|
||||||
|
var s = new BoostStore(_path);
|
||||||
|
s.Set("www.example.com", true, "");
|
||||||
|
Assert.True(s.Get("example.com").ForceDark); // www 정규화
|
||||||
|
var js = BoostCssBuilder.BuildScript("plain.org", s.Get("plain.org"));
|
||||||
|
Assert.Equal("", js); // 미설정 사이트 → 빈 스크립트
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 강제다크_스크립트는_반전필터를_포함하고_이미지_비디오를_보호한다()
|
||||||
|
{
|
||||||
|
var js = BoostCssBuilder.BuildScript("d.com", new BoostSettings(ForceDark: true, "h1{color:red}"));
|
||||||
|
Assert.Contains("invert(1)", js);
|
||||||
|
Assert.Contains("img", js); // 미디어 보호 셀렉터
|
||||||
|
Assert.Contains("video", js);
|
||||||
|
Assert.Contains("h1{color:red}", js);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { try { File.Delete(_path); } catch { } }
|
||||||
|
}
|
||||||
78
VideoDownloader.Tests/Browser/TabGroupsWorkspacesTests.cs
Normal file
78
VideoDownloader.Tests/Browser/TabGroupsWorkspacesTests.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
using VideoDownloader.Browser.Tabs;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Tests.Browser;
|
||||||
|
|
||||||
|
// ── T2-11 탭 그룹: 색상 라벨 + 그룹 단위 이동/닫기 ──
|
||||||
|
public class TabGroupsTests
|
||||||
|
{
|
||||||
|
private static TabManager NewManager() => new(() => new TabItem());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 그룹_지정과_해제()
|
||||||
|
{
|
||||||
|
var m = NewManager();
|
||||||
|
var t = m.NewTab("https://a.com");
|
||||||
|
m.SetGroup(t, "Work", "#e11");
|
||||||
|
Assert.Equal("Work", t.GroupName);
|
||||||
|
Assert.Equal("#e11", t.GroupColor);
|
||||||
|
m.SetGroup(t, null, null);
|
||||||
|
Assert.Null(t.GroupName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 같은_그룹_탭은_서로_인접한다()
|
||||||
|
{
|
||||||
|
var m = NewManager();
|
||||||
|
var a = m.NewTab("a"); m.SetGroup(a, "G", "#111");
|
||||||
|
m.NewTab("b"); // 비그룹
|
||||||
|
var c = m.NewTab("c"); m.SetGroup(c, "G", "#111"); // 그룹으로 → a 옆으로 이동
|
||||||
|
Assert.Equal(1, m.Tabs.IndexOf(c));
|
||||||
|
Assert.Equal(0, m.Tabs.IndexOf(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 그룹_탭_전체_닫기()
|
||||||
|
{
|
||||||
|
var m = NewManager();
|
||||||
|
var a = m.NewTab("a"); m.SetGroup(a, "G", "#111");
|
||||||
|
var b = m.NewTab("b"); m.SetGroup(b, "G", "#111");
|
||||||
|
m.NewTab("solo");
|
||||||
|
var closed = m.CloseGroup("G");
|
||||||
|
Assert.Equal(2, closed);
|
||||||
|
Assert.Single(m.Tabs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T3-1 워크스페이스: 탭 세트 저장/전환 ──
|
||||||
|
public class WorkspaceTests
|
||||||
|
{
|
||||||
|
private static TabManager NewManager() => new(() => new TabItem());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 워크스페이스_저장후_스왑하면_탭_세트가_교체된다()
|
||||||
|
{
|
||||||
|
var m = NewManager();
|
||||||
|
m.NewTab("https://work1.com");
|
||||||
|
m.NewTab("https://work2.com");
|
||||||
|
var ws = m.CaptureWorkspace("Work");
|
||||||
|
|
||||||
|
m.NewTab("https://personal.com");
|
||||||
|
Assert.Equal(3, m.Tabs.Count);
|
||||||
|
|
||||||
|
m.SwitchWorkspace(ws);
|
||||||
|
Assert.Equal(2, m.Tabs.Count);
|
||||||
|
Assert.Equal("https://work1.com", m.Tabs[0].Url);
|
||||||
|
Assert.Equal("https://work2.com", m.Tabs[1].Url);
|
||||||
|
Assert.Equal("Work", ws.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 워크스페이스_캡처는_현재_탭을_그대로_유지한다()
|
||||||
|
{
|
||||||
|
var m = NewManager();
|
||||||
|
m.NewTab("https://a.com");
|
||||||
|
var ws = m.CaptureWorkspace("X");
|
||||||
|
Assert.Equal(1, m.Tabs.Count); // 캡처 자체는 현재 상태 유지(스냅샷)
|
||||||
|
Assert.Single(ws.Urls);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
VideoDownloader.Tests/E2E/ReaderModeE2ETests.cs
Normal file
85
VideoDownloader.Tests/E2E/ReaderModeE2ETests.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
using System.Windows;
|
||||||
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
using VideoDownloader.App.Browser.Features;
|
||||||
|
using VideoDownloader.Browser.Tabs;
|
||||||
|
using VideoDownloader.Tests.Infrastructure;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Tests.E2E;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// E2E (T2-4): 실제 WebView2 + 번들된 Mozilla Readability.js 로
|
||||||
|
/// 기사 페이지에서 본문을 추출해 리더 뷰로 교체하고 원본 URL 복귀까지 검증.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("E2E")]
|
||||||
|
public class ReaderModeE2ETests
|
||||||
|
{
|
||||||
|
private const string ArticleHtml = """
|
||||||
|
<html><head><title>테스트 기사</title></head><body>
|
||||||
|
<nav>광고 광고 광고 내비게이션</nav>
|
||||||
|
<article>
|
||||||
|
<h1>리더 모드 E2E 기사</h1>
|
||||||
|
<p>첫 번째 문단입니다. 이 문단은 Readability 가 본문으로 추출해야 합니다. 충분한 길이의 텍스트가 필요하므로 몇 번 더 반복합니다. 본문 본문 본문.</p>
|
||||||
|
<p>두 번째 문단입니다. 리더 모드는 광고와 내비게이션을 제거하고 본문만 남깁니다. 추출 결과에 이 텍스트가 포함되어야 합니다.</p>
|
||||||
|
<p>세 번째 문단입니다. 문단이 세 개 이상이면 Readability 의 휴리스틱이 본문으로 판정합니다. 마커텍스트-고유문구-레인보우4747.</p>
|
||||||
|
</article>
|
||||||
|
<footer>푸터 링크 모음</footer>
|
||||||
|
</body></html>
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task 기사를_리더로_추출하고_원본으로_복귀한다()
|
||||||
|
{
|
||||||
|
using var sta = StaDispatcher.Start();
|
||||||
|
var udf = Path.Combine(Path.GetTempPath(), $"vd_reader_{Guid.NewGuid():N}");
|
||||||
|
var ok = await sta.InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
var window = new Window();
|
||||||
|
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
|
||||||
|
window.Content = browser;
|
||||||
|
window.Show();
|
||||||
|
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
|
||||||
|
await browser.EnsureCoreWebView2Async(env);
|
||||||
|
var cv = browser.CoreWebView2;
|
||||||
|
|
||||||
|
cv.NavigateToString(ArticleHtml);
|
||||||
|
await WaitNav(cv);
|
||||||
|
|
||||||
|
var tab = new TabItem { View = browser };
|
||||||
|
|
||||||
|
// 1) 리더 진입
|
||||||
|
var entered = await ReaderMode.ToggleAsync(tab, lightTheme: false);
|
||||||
|
await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""), t => t.Contains("테스트 기사"), 15000);
|
||||||
|
var titleAfter = cv.DocumentTitle ?? "";
|
||||||
|
var bodyText = await cv.ExecuteScriptAsync("document.body.innerText");
|
||||||
|
var hasMarker = bodyText.Contains("마커텍스트-고유문구-레인보우4747");
|
||||||
|
var hasNav = bodyText.Contains("광고 광고 광고");
|
||||||
|
var isReader = tab.IsReader;
|
||||||
|
var originalSaved = tab.ReaderOriginalUrl;
|
||||||
|
|
||||||
|
// 2) 복귀
|
||||||
|
var exited = await ReaderMode.ToggleAsync(tab, lightTheme: false);
|
||||||
|
var restoredReader = tab.IsReader == false;
|
||||||
|
|
||||||
|
window.Close();
|
||||||
|
return (entered: entered == true, titleAfter, hasMarker, navRemoved: !hasNav, isReader, originalSaved, exited: exited == false, restoredReader);
|
||||||
|
});
|
||||||
|
|
||||||
|
try { Directory.Delete(udf, true); } catch { }
|
||||||
|
Assert.True(ok.entered, "리더 진입 실패");
|
||||||
|
Assert.Contains("테스트 기사", ok.titleAfter);
|
||||||
|
Assert.True(ok.hasMarker, "본문 마커 누락");
|
||||||
|
Assert.True(ok.navRemoved, "내비/광고 제거 안됨");
|
||||||
|
Assert.True(ok.isReader);
|
||||||
|
Assert.True(ok.exited);
|
||||||
|
Assert.True(ok.restoredReader);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task WaitNav(CoreWebView2 cv)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
EventHandler<CoreWebView2NavigationCompletedEventArgs>? h = null;
|
||||||
|
h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
|
||||||
|
cv.NavigationCompleted += h;
|
||||||
|
return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue