feat(ios): add font selection list with live previews
This commit is contained in:
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
+1008
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user