Phase 0 (T1-3/4/7/9/11): find bar w/ native Find API + search engine + site zoom + force reload + F11, wired into MainWindow, E2E smoke (39 tests)

This commit is contained in:
Yun Chan 2026-08-15 12:14:35 +09:00
parent 470c9267f2
commit 00a822eaeb
11 changed files with 584 additions and 18 deletions

View file

@ -0,0 +1,126 @@
using Microsoft.Web.WebView2.Core;
namespace VideoDownloader.App.Browser;
/// <summary>네이티브 Find API 의 최소 추상화(단위 테스트용 fake 교체 가능).</summary>
public interface IFindApi
{
Task StartAsync(string term, bool caseSensitive, bool matchWord, bool highlightAll);
void FindNext();
void FindPrevious();
void Stop();
event EventHandler<int>? MatchCountChanged;
event EventHandler<int>? ActiveMatchIndexChanged;
}
/// <summary>WebView2 네이티브 Find(CoreWebView2.Find) 를 IFindApi 로 감싼 어댑터.
/// 옵션 객체는 public ctor 가 없어 CoreWebView2Environment.CreateFindOptions() 로 생성.</summary>
public sealed class WebView2FindApi : IFindApi
{
private readonly CoreWebView2 _cv;
private readonly CoreWebView2Environment _env;
public WebView2FindApi(CoreWebView2 cv, CoreWebView2Environment env)
{
_cv = cv;
_env = env;
}
public Task StartAsync(string term, bool caseSensitive, bool matchWord, bool highlightAll)
{
var o = _env.CreateFindOptions();
o.FindTerm = term;
o.IsCaseSensitive = caseSensitive;
o.ShouldMatchWord = matchWord;
o.ShouldHighlightAllMatches = highlightAll;
o.SuppressDefaultFindDialog = true; // 자체 WPF 검색 바 사용
return _cv.Find.StartAsync(o);
}
public void FindNext() => _cv.Find.FindNext();
public void FindPrevious() => _cv.Find.FindPrevious();
public void Stop() => _cv.Find.Stop();
// 이벤트 인자가 object 로 전달되므로 매치 수는 Find 프로퍼티에서 직접 읽는다.
public event EventHandler<int>? MatchCountChanged
{
add => _cv.Find.MatchCountChanged += (s, _) => value?.Invoke(s, _cv.Find.MatchCount);
remove { }
}
public event EventHandler<int>? ActiveMatchIndexChanged
{
add => _cv.Find.ActiveMatchIndexChanged += (s, _) => value?.Invoke(s, _cv.Find.ActiveMatchIndex);
remove { }
}
}
/// <summary>
/// 페이지 내 검색 세션 상태 머신(T1-3). 용어·매치 수·활성 인덱스를 유지하고
/// UI(검색 바)가 표시할 "3 / 17" 텍스트를 제공한다.
/// </summary>
public sealed class FindSession : IDisposable
{
private readonly IFindApi _api;
public string Term { get; private set; } = string.Empty;
public int MatchCount { get; private set; }
public int ActiveMatchIndex { get; private set; }
public bool IsCaseSensitive { get; private set; }
public bool MatchWholeWord { get; private set; }
public event Action? StateChanged;
public FindSession(IFindApi api)
{
_api = api;
_api.MatchCountChanged += (_, n) => { MatchCount = n; StateChanged?.Invoke(); };
_api.ActiveMatchIndexChanged += (_, n) => { ActiveMatchIndex = n; StateChanged?.Invoke(); };
}
public string StatusText
{
get
{
if (MatchCount <= 0) return "결과 없음";
var oneBased = ActiveMatchIndex >= 0 ? ActiveMatchIndex + 1 : 0;
return $"{oneBased} / {MatchCount}";
}
}
public async Task StartAsync(string term)
{
Term = term ?? "";
if (string.IsNullOrWhiteSpace(term))
{
Close();
return;
}
MatchCount = 0;
ActiveMatchIndex = -1;
await _api.StartAsync(Term, IsCaseSensitive, MatchWholeWord, highlightAll: true);
StateChanged?.Invoke();
}
public void SetOptions(bool caseSensitive, bool matchWholeWord)
{
IsCaseSensitive = caseSensitive;
MatchWholeWord = matchWholeWord;
if (!string.IsNullOrWhiteSpace(Term))
_ = StartAsync(Term); // 옵션 변경은 재시작 필요(네이티브 제약)
}
public void Next() => _api.FindNext();
public void Previous() => _api.FindPrevious();
public void Close()
{
_api.Stop();
Term = string.Empty;
MatchCount = 0;
ActiveMatchIndex = -1;
StateChanged?.Invoke();
}
public void Dispose() => Close();
}