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:
Yun Chan 2026-08-15 15:22:44 +09:00
parent 0caa1111e7
commit 406957088f
14 changed files with 3496 additions and 7 deletions

View 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;
}
}

View file

@ -37,3 +37,27 @@ public sealed class BoolToClosedConverter : IValueConverter
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
=> 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();
}

View 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>""";
}
}

View 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 { }
};
}
}