diff --git a/apps/ios/EdgeEver.xcodeproj/project.pbxproj b/apps/ios/EdgeEver.xcodeproj/project.pbxproj index 48b50d7a..8d7c0387 100644 --- a/apps/ios/EdgeEver.xcodeproj/project.pbxproj +++ b/apps/ios/EdgeEver.xcodeproj/project.pbxproj @@ -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 = ""; }; + 04B6BCA279F8E8A4774A7C0A /* NoteImageFontPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteImageFontPickerView.swift; sourceTree = ""; }; 0531FC6AA81DAF0A55B42362 /* AppDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = ""; }; 0EF7E97C950002FA22EFB7C8 /* AiDirectStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiDirectStream.swift; sourceTree = ""; }; 12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncProtocol.swift; sourceTree = ""; }; @@ -138,6 +141,7 @@ 243811C4B4CBF13982976A56 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 2829CE5DF5C15ACF41CC17DA /* EditFabButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFabButton.swift; sourceTree = ""; }; 2A8662720DE42695E29F5741 /* URL+Normalize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Normalize.swift"; sourceTree = ""; }; + 2D78B33F7B6D7E017F63E455 /* font-picker.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = "font-picker.html"; sourceTree = ""; }; 328A2DAC7E7DC89F4782B5FB /* RevisionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevisionsView.swift; sourceTree = ""; }; 34A3C0399C910B150A02D06E /* ResourceCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceCache.swift; sourceTree = ""; }; 3674473015F8EB6863B69B0F /* MemoEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoEditView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift index 23ca7366..e63addbc 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift @@ -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.", diff --git a/apps/ios/EdgeEver/Features/Workspace/NoteImageFontPickerView.swift b/apps/ios/EdgeEver/Features/Workspace/NoteImageFontPickerView.swift new file mode 100644 index 00000000..cb33f4af --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/NoteImageFontPickerView.swift @@ -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() } + } +} diff --git a/apps/ios/EdgeEver/Resources/EditorBundle/font-picker.html b/apps/ios/EdgeEver/Resources/EditorBundle/font-picker.html new file mode 100644 index 00000000..756150ce --- /dev/null +++ b/apps/ios/EdgeEver/Resources/EditorBundle/font-picker.html @@ -0,0 +1,1008 @@ + + + + + + Typography + + + +
+ + diff --git a/apps/ios/EditorBundle/font-picker.html b/apps/ios/EditorBundle/font-picker.html new file mode 100644 index 00000000..756150ce --- /dev/null +++ b/apps/ios/EditorBundle/font-picker.html @@ -0,0 +1,1008 @@ + + + + + + Typography + + + +
+ + diff --git a/apps/ios/EditorSource/font-picker.html b/apps/ios/EditorSource/font-picker.html new file mode 100644 index 00000000..6ec166ff --- /dev/null +++ b/apps/ios/EditorSource/font-picker.html @@ -0,0 +1,12 @@ + + + + + + Typography + + +
+ + + diff --git a/apps/ios/EditorSource/package.json b/apps/ios/EditorSource/package.json index 2a81a6b8..f021a02a 100644 --- a/apps/ios/EditorSource/package.json +++ b/apps/ios/EditorSource/package.json @@ -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" }, diff --git a/apps/ios/EditorSource/src/font-picker.ts b/apps/ios/EditorSource/src/font-picker.ts new file mode 100644 index 00000000..03155deb --- /dev/null +++ b/apps/ios/EditorSource/src/font-picker.ts @@ -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(["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" }); +} diff --git a/apps/ios/EditorSource/vite.font-picker.config.ts b/apps/ios/EditorSource/vite.font-picker.config.ts new file mode 100644 index 00000000..74fda483 --- /dev/null +++ b/apps/ios/EditorSource/vite.font-picker.config.ts @@ -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" }, + }, + }, +}); diff --git a/apps/ios/Scripts/build-editor-bundle.sh b/apps/ios/Scripts/build-editor-bundle.sh index 0e8a49e3..7019f58d 100755 --- a/apps/ios/Scripts/build-editor-bundle.sh +++ b/apps/ios/Scripts/build-editor-bundle.sh @@ -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/" diff --git a/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift b/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift index d8fdeb27..ff93f211 100644 --- a/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift +++ b/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift @@ -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())