feat(ios): add font selection list with live previews

This commit is contained in:
tianma-if committed 2026-10-18 19:53:36 +08:00
1 parent d4d3df562c
commit eb034b3c8b
11 files changed
+2369 -11

No files matched your search

@@ -35,6 +35,7 @@
4A2DD0673B81D32FA9E5F9DB /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F590FE9371571DC7456154 /* Theme.swift */; };
4A771542C5AD2E6A907187AD /* OutboxFlusher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8720E0D6F00D1509B76A374B /* OutboxFlusher.swift */; };
4BA97CD264D0929B20ED19DB /* TipTapWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */; };
4D26C62561A7690325E55210 /* NoteImageFontPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6BCA279F8E8A4774A7C0A /* NoteImageFontPickerView.swift */; };
4EC901852EDA4BC001717747 /* TipTapContentSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A536119D29812EF11DAEAA8 /* TipTapContentSourceTests.swift */; };
4F3977342A76C352E95E14E7 /* MemoTemplates.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F36ACF8073C8A61AE397CD /* MemoTemplates.swift */; };
4F49C2894D3842B4DEAFAD5E /* WebClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13105CFE9131425A1C7E4BF0 /* WebClipper.swift */; };
@@ -48,6 +49,7 @@
5EEFDEEDC372725C7C7F22ED /* EdgeEverApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9CEA2902CDE0D7C4137D173 /* EdgeEverApp.swift */; };
5F5EC9565A02FAFDD5D7B54C /* SyncProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */; };
63D4B562B4EF6ED7291155F6 /* TemplatePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BA3D6859C4C6D9F65435671 /* TemplatePickerSheet.swift */; };
67DBD3C7748DE7F8BAD8E752 /* font-picker.html in Resources */ = {isa = PBXBuildFile; fileRef = 2D78B33F7B6D7E017F63E455 /* font-picker.html */; };
6A671BB702DA77A0548E6E29 /* URL+Normalize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8662720DE42695E29F5741 /* URL+Normalize.swift */; };
6B2D55E0EA4D28EB7171A565 /* SystemImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A2092411EFC3765B97DF88 /* SystemImagePicker.swift */; };
6E1459D0AA2E0217C517F973 /* WebClipperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7D3262D1E3E40E38361E84 /* WebClipperTests.swift */; };
@@ -123,6 +125,7 @@
/* Begin PBXFileReference section */
01D34117A78B1F5C367ACDBB /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = assets; path = EdgeEver/Resources/EditorBundle/assets; sourceTree = SOURCE_ROOT; };
033565CE2E96EC2D23105188 /* ListActionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListActionsSheet.swift; sourceTree = "<group>"; };
04B6BCA279F8E8A4774A7C0A /* NoteImageFontPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteImageFontPickerView.swift; sourceTree = "<group>"; };
0531FC6AA81DAF0A55B42362 /* AppDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = "<group>"; };
0EF7E97C950002FA22EFB7C8 /* AiDirectStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiDirectStream.swift; sourceTree = "<group>"; };
12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncProtocol.swift; sourceTree = "<group>"; };
@@ -138,6 +141,7 @@
243811C4B4CBF13982976A56 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
2829CE5DF5C15ACF41CC17DA /* EditFabButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFabButton.swift; sourceTree = "<group>"; };
2A8662720DE42695E29F5741 /* URL+Normalize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Normalize.swift"; sourceTree = "<group>"; };
2D78B33F7B6D7E017F63E455 /* font-picker.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = "font-picker.html"; sourceTree = "<group>"; };
328A2DAC7E7DC89F4782B5FB /* RevisionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevisionsView.swift; sourceTree = "<group>"; };
34A3C0399C910B150A02D06E /* ResourceCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceCache.swift; sourceTree = "<group>"; };
3674473015F8EB6863B69B0F /* MemoEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoEditView.swift; sourceTree = "<group>"; };
@@ -345,6 +349,7 @@
isa = PBXGroup;
children = (
01D34117A78B1F5C367ACDBB /* assets */,
2D78B33F7B6D7E017F63E455 /* font-picker.html */,
AB02424D0412FE36C4BCD116 /* index.html */,
);
path = EditorBundle;
@@ -420,6 +425,7 @@
3674473015F8EB6863B69B0F /* MemoEditView.swift */,
59C38DBB41BA706C678AB9BD /* MemoEditViewModel.swift */,
E3328D871910187ED827B6CE /* NotebookPickerSheet.swift */,
04B6BCA279F8E8A4774A7C0A /* NoteImageFontPickerView.swift */,
B30C95F4EABCD08555D97C6B /* NotesListView.swift */,
328A2DAC7E7DC89F4782B5FB /* RevisionsView.swift */,
B1D94BB61519CA2A345D99C1 /* SettingsView.swift */,
@@ -622,6 +628,7 @@
06D2661CAB0ADED27F322649 /* Localizable.xcstrings in Resources */,
C37DA99E090A783562506CDA /* PrivacyInfo.xcprivacy in Resources */,
8E1CB597E6534EBFAD4B43FE /* assets in Resources */,
67DBD3C7748DE7F8BAD8E752 /* font-picker.html in Resources */,
08D0159B8F194E04C02B69BF /* index.html in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -690,6 +697,7 @@
506702F1751945424AC4CB98 /* MobileUI.swift in Sources */,
90FFFFD00B2D19F96EA299C8 /* Models.swift in Sources */,
50800DF91A760DE7FB68DFB4 /* Motion.swift in Sources */,
4D26C62561A7690325E55210 /* NoteImageFontPickerView.swift in Sources */,
0614453E8E9EC13B212B5A2F /* NotebookPickerSheet.swift in Sources */,
B08E58823845817F84C52A52 /* NotesListView.swift in Sources */,
4A771542C5AD2E6A907187AD /* OutboxFlusher.swift in Sources */,
@@ -193,16 +193,17 @@ struct MemoDetailView: View {
.pickerStyle(.menu)
}
Section(env.preferences.t("字体风格", en: "Typography", pl: "Typografia")) {
Picker(env.preferences.t("字体风格", en: "Typography", pl: "Typografia"), selection: $preferences.noteImageShare.fontStyle) {
Text(env.preferences.t("跟随主题", en: "Follow Theme", pl: "Zgodnie z motywem")).tag("auto")
Text(env.preferences.t("文艺衬线", en: "Serif", pl: "Szeryfowa")).tag("serif")
Text(env.preferences.t("现代无衬线", en: "Sans", pl: "Bezszeryfowa")).tag("sans")
Text(env.preferences.t("极客等宽", en: "Mono", pl: "Monospace")).tag("mono")
ForEach(NoteImageFontCatalog.choices, id: \.id) { font in
Text(env.preferences.t(font.zh, en: font.en, pl: font.en)).tag(font.id)
NavigationLink {
NoteImageFontPickerView()
} label: {
HStack {
Text(env.preferences.t("字体风格", en: "Typography", pl: "Typografia"))
Spacer()
Text(NoteImageFontPickerOptions.label(preferences))
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
}
.pickerStyle(.menu)
Text(env.preferences.t(
"下载型字体首次使用需联网并缓存;普惠体等推荐字体使用完整文件,首次下载较大。",
en: "Downloadable fonts need a connection on first use and are cached. PuHuiTi and other recommended fonts use larger, complete original files.",
@@ -0,0 +1,147 @@
import SwiftUI
import WebKit
@MainActor
enum NoteImageFontPickerOptions {
static func choices(_ preferences: PreferencesStore) -> [(id: String, label: String)] {
[
("auto", preferences.t("跟随主题", en: "Follow Theme", pl: "Zgodnie z motywem")),
("kai", preferences.t("文楷手书", en: "Handwritten Kai", pl: "Pismo odręczne Kai")),
("serif", preferences.t("文艺衬线", en: "Serif", pl: "Szeryfowa")),
("sans", preferences.t("现代无衬线", en: "Sans", pl: "Bezszeryfowa")),
("mono", preferences.t("极客等宽", en: "Mono", pl: "Monospace")),
] + NoteImageFontCatalog.choices.map { ($0.id, preferences.t($0.zh, en: $0.en, pl: $0.en)) }
}
static func label(_ preferences: PreferencesStore) -> String {
choices(preferences).first { $0.id == preferences.noteImageShare.fontStyle }?.label ?? ""
}
}
struct NoteImageFontPickerView: View {
@Environment(AppEnvironment.self) private var env
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var loading = true
@State private var failed = false
@State private var attempt = 0
var body: some View {
ZStack {
if let url = NoteImageFontPickerWebView.packagedHTMLURL() {
NoteImageFontPickerWebView(
url: url,
configuration: configuration,
onReady: { loading = false },
onFailure: { loading = false; failed = true },
onSelect: { id in
env.preferences.noteImageShare.fontStyle = id
dismiss()
}
)
.id(attempt)
.opacity(failed ? 0 : 1)
if loading { ProgressView() }
if failed {
ContentUnavailableView {
Label(env.preferences.t("加载失败", en: "Failed to load", pl: "Nie udało się wczytać"), systemImage: "exclamationmark.triangle")
} actions: {
Button(env.preferences.t("重试", en: "Retry", pl: "Spróbuj ponownie")) {
failed = false
loading = true
attempt += 1
}
}
}
} else {
ContentUnavailableView(env.preferences.t("加载失败", en: "Failed to load", pl: "Nie udało się wczytać"), systemImage: "exclamationmark.triangle")
}
}
.navigationTitle(env.preferences.t("字体风格", en: "Typography", pl: "Typografia"))
.navigationBarTitleDisplayMode(.inline)
}
private var configuration: [String: Any] {
let preferences = env.preferences
return [
"selected": preferences.noteImageShare.fontStyle,
"theme": preferences.noteImageShare.theme,
"dark": colorScheme == .dark,
"title": preferences.t("字体风格", en: "Typography", pl: "Typografia"),
"sample": preferences.t("记录此刻的灵感", en: "Capture a little inspiration", ja: "今この瞬間のひらめきを記録", pl: "Zapisz chwilę inspiracji"),
"latinSample": "Capture a little inspiration",
"loading": preferences.t("正在加载示例…", en: "Loading preview…", ja: "プレビューを読み込み中…", pl: "Ładowanie podglądu…"),
"unavailable": preferences.t("示例暂不可用,仍可选择", en: "Preview unavailable; you can still select this font", ja: "プレビューを表示できませんが、選択できます", pl: "Podgląd niedostępny; nadal możesz wybrać tę czcionkę"),
"options": NoteImageFontPickerOptions.choices(preferences).map { ["id": $0.id, "label": $0.label] },
]
}
}
struct NoteImageFontPickerWebView: UIViewRepresentable {
let url: URL
let configuration: [String: Any]
let onReady: () -> Void
let onFailure: () -> Void
let onSelect: (String) -> Void
static func packagedHTMLURL() -> URL? {
Bundle.main.url(forResource: "font-picker", withExtension: "html", subdirectory: "EditorBundle")
?? Bundle.main.url(forResource: "font-picker", withExtension: "html")
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
config.mediaTypesRequiringUserActionForPlayback = .all
config.userContentController.add(context.coordinator, name: "fontPicker")
if let data = try? JSONSerialization.data(withJSONObject: configuration),
let json = String(data: data, encoding: .utf8) {
config.userContentController.addUserScript(WKUserScript(
source: "window.EdgeEverFontPickerConfiguration = \(json);",
injectionTime: .atDocumentStart,
forMainFrameOnly: true
))
}
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.backgroundColor = .clear
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {
context.coordinator.parent = self
}
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
uiView.stopLoading()
uiView.navigationDelegate = nil
uiView.configuration.userContentController.removeScriptMessageHandler(forName: "fontPicker")
}
final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
var parent: NoteImageFontPickerWebView
init(_ parent: NoteImageFontPickerWebView) { self.parent = parent }
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.frameInfo.isMainFrame, let body = message.body as? [String: String] else { return }
switch body["type"] {
case "ready": parent.onReady()
case "select":
guard let id = body["id"],
let choices = parent.configuration["options"] as? [[String: String]],
choices.contains(where: { $0["id"] == id }) else { return }
parent.onSelect(id)
default: break
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { parent.onFailure() }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { parent.onFailure() }
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { parent.onFailure() }
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Typography</title>
</head>
<body>
<main id="font-list" role="radiogroup"></main>
<script type="module" src="/src/font-picker.ts"></script>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "vite build",
"build": "vite build && vite build --config vite.font-picker.config.ts",
"dev": "vite",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
+109
View File
@@ -0,0 +1,109 @@
import { FONT_IDS, isRemoteNoteImageFont } from "@edgeever/shared/font-catalog";
import { resolveNoteImageTypography, type NoteImageFontStyle, type NoteImageTheme } from "@edgeever/shared/note-image-card";
import { prepareNoteImageAssets } from "@edgeever/shared/note-image-fonts";
import { noteImageAssetUrls } from "@edgeever/shared/note-image-assets";
type PickerConfiguration = {
selected: NoteImageFontStyle;
theme: NoteImageTheme;
dark: boolean;
title: string;
sample: string;
latinSample: string;
loading: string;
unavailable: string;
options: Array<{ id: NoteImageFontStyle; label: string }>;
};
type PickerWindow = Window & {
EdgeEverFontPickerConfiguration?: PickerConfiguration;
webkit?: { messageHandlers?: { fontPicker?: { postMessage: (message: unknown) => void } } };
};
const host = window as PickerWindow;
const config = host.EdgeEverFontPickerConfiguration;
const list = document.getElementById("font-list");
if (config && list) {
const supported = new Set<string>(["auto", "kai", "serif", "sans", "mono", ...FONT_IDS]);
const latinFonts = new Set(["anton", "inter", "dmserif", "bebas", "spacegrotesk", "jetbrains", "mono"]);
const style = document.createElement("style");
style.textContent = `
:root { color-scheme: ${config.dark ? "dark" : "light"}; font: -apple-system-body; }
* { box-sizing: border-box; }
html, body { margin: 0; background: ${config.dark ? "#111c18" : "#ffffff"}; color: ${config.dark ? "#f8fafc" : "#0f172a"}; }
#font-list { padding: 0 16px max(16px, env(safe-area-inset-bottom)); }
.font-option { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; min-height: 88px; padding: 14px 12px; border: 0; border-bottom: 1px solid ${config.dark ? "#26382f" : "#e2e8f0"}; background: transparent; color: inherit; text-align: left; font: inherit; }
.font-option[aria-checked="true"] { background: ${config.dark ? "#0b3328" : "#ecfdf5"}; }
.font-option:focus-visible { outline: 2px solid #16a06e; outline-offset: -2px; }
.font-copy { min-width: 0; }
.font-name { display: block; font: -apple-system-subheadline; font-weight: 600; }
.font-sample { display: block; margin-top: 6px; font-size: 1.2rem; line-height: 1.5; overflow-wrap: anywhere; }
.font-status { display: block; margin-top: 6px; color: ${config.dark ? "#94a3b8" : "#64748b"}; font: -apple-system-footnote; }
.font-check { flex: 0 0 24px; color: ${config.dark ? "#6ee7b7" : "#16a06e"}; font-size: 24px; font-weight: 700; }
`;
document.head.append(style);
list.setAttribute("aria-label", config.title);
const rows: Array<{ button: HTMLButtonElement; sample: HTMLSpanElement; id: NoteImageFontStyle; text: string }> = [];
let choosing = false;
for (const item of config.options) {
if (!supported.has(item.id)) continue;
const text = latinFonts.has(item.id) ? config.latinSample : config.sample;
const button = document.createElement("button");
button.type = "button";
button.className = "font-option";
button.dataset.font = item.id;
button.setAttribute("role", "radio");
button.setAttribute("aria-checked", String(item.id === config.selected));
const copy = document.createElement("span");
copy.className = "font-copy";
const name = document.createElement("span");
name.className = "font-name";
name.textContent = item.label;
const sample = document.createElement("span");
const needsFont = item.id === "auto" || isRemoteNoteImageFont(item.id);
sample.className = needsFont ? "font-status" : "font-sample";
sample.textContent = needsFont ? config.loading : text;
if (!needsFont) sample.style.fontFamily = resolveNoteImageTypography(config.theme, item.id).bodyFamily;
copy.append(name, sample);
const check = document.createElement("span");
check.className = "font-check";
check.setAttribute("aria-hidden", "true");
check.textContent = item.id === config.selected ? "✓" : "";
button.append(copy, check);
button.addEventListener("click", () => {
const bridge = host.webkit?.messageHandlers?.fontPicker;
if (choosing || !bridge) return;
choosing = true;
bridge.postMessage({ type: "select", id: item.id });
});
list.append(button);
rows.push({ button, sample, id: item.id, text });
}
// Register only visible previews, using the exporter's existing byte cache.
const byElement = new Map(rows.map(row => [row.button, row]));
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
observer.unobserve(entry.target);
const row = byElement.get(entry.target as HTMLButtonElement);
if (!row) continue;
void prepareNoteImageAssets({ theme: config.theme, fontStyle: row.id, text: row.text, urls: noteImageAssetUrls, embed: false })
.then(() => {
row.sample.className = "font-sample";
row.sample.style.fontFamily = resolveNoteImageTypography(config.theme, row.id).bodyFamily;
row.sample.textContent = row.text;
})
.catch(() => {
row.sample.className = "font-status";
row.sample.textContent = config.unavailable;
});
}
});
rows.forEach(row => observer.observe(row.button));
list.querySelector('[aria-checked="true"]')?.scrollIntoView({ block: "center" });
window.addEventListener("pagehide", () => observer.disconnect(), { once: true });
host.webkit?.messageHandlers?.fontPicker?.postMessage({ type: "ready" });
}
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import editorConfig from "./vite.config";
// A separate entry keeps the chooser independent of the long-lived editor.
export default defineConfig({
...editorConfig,
build: {
...editorConfig.build,
emptyOutDir: false,
rollupOptions: {
input: "font-picker.html",
output: { inlineDynamicImports: true, entryFileNames: "font-picker.js" },
},
},
});
+3 -2
View File
@@ -19,8 +19,8 @@ else
fi
# Ensure index.html exists at EditorBundle root for iOS resource load.
if [[ ! -f "$OUT/index.html" ]]; then
echo "Editor bundle build did not produce index.html" >&2
if [[ ! -f "$OUT/index.html" || ! -f "$OUT/font-picker.html" ]]; then
echo "Editor bundle build did not produce index.html and font-picker.html" >&2
exit 1
fi
@@ -28,6 +28,7 @@ fi
RES_OUT="$ROOT/EdgeEver/Resources/EditorBundle"
mkdir -p "$RES_OUT"
cp -f "$OUT/index.html" "$RES_OUT/index.html"
cp -f "$OUT/font-picker.html" "$RES_OUT/font-picker.html"
if [[ -d "$OUT/assets" ]]; then
mkdir -p "$RES_OUT/assets"
rsync -a --delete "$OUT/assets/" "$RES_OUT/assets/"
@@ -4,6 +4,55 @@ import WebKit
/// Ensures the TipTap editor HTML is actually shipped in the app (not the plain-text fallback).
final class EditorBundleTests: XCTestCase {
@MainActor
func testFontPickerLoadsBundledPreviewAndReturnsSelection() async throws {
let url = try XCTUnwrap(NoteImageFontPickerWebView.packagedHTMLURL())
let ready = expectation(description: "font picker ready")
let selected = expectation(description: "font picker selection")
final class Loader: NSObject, WKScriptMessageHandler {
let ready: XCTestExpectation
let selected: XCTestExpectation
var selection: String?
init(_ ready: XCTestExpectation, _ selected: XCTestExpectation) {
self.ready = ready
self.selected = selected
}
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: String] else { return }
if body["type"] == "ready" { ready.fulfill() }
if body["type"] == "select" { selection = body["id"]; selected.fulfill() }
}
}
let loader = Loader(ready, selected)
let config = WKWebViewConfiguration()
config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
config.userContentController.add(loader, name: "fontPicker")
config.userContentController.addUserScript(WKUserScript(source: """
window.EdgeEverFontPickerConfiguration = {
selected:'auto', theme:'code', dark:false, title:'Typography',
sample:'Capture a little inspiration', latinSample:'Capture a little inspiration',
loading:'Loading preview', unavailable:'Preview unavailable',
options:[{id:'auto',label:'Follow Theme'},{id:'sans',label:'Sans'},{id:'mono',label:'Mono'}]
};
""", injectionTime: .atDocumentStart, forMainFrameOnly: true))
defer { config.userContentController.removeScriptMessageHandler(forName: "fontPicker") }
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844), configuration: config)
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
await fulfillment(of: [ready], timeout: 15)
var previewReady = false
for _ in 0..<100 {
previewReady = (try await webView.evaluateJavaScript(#"document.querySelector('[data-font=auto] .font-sample') !== null && document.fonts.check('20px "Geist Mono Variable"')"#)) as? Bool == true
if previewReady { break }
try await Task.sleep(nanoseconds: 100_000_000)
}
XCTAssertTrue(previewReady, "theme preview must load its actual bundled font without a network request")
let selectedID = try await webView.evaluateJavaScript("document.querySelector('[aria-checked=true]').dataset.font")
XCTAssertEqual(selectedID as? String, "auto")
_ = try await webView.evaluateJavaScript("document.querySelector('[data-font=sans]').click(); true")
await fulfillment(of: [selected], timeout: 5)
XCTAssertEqual(loader.selection, "sans")
}
@MainActor
func testSharedFallbackSurvivesEditorConfigurationAndReuse() async throws {
let url = try XCTUnwrap(TipTapWebView.Coordinator.packagedEditorHTMLURL())