From b367d7d4ab84d1e0def250acf254ec63b51a47d5 Mon Sep 17 00:00:00 2001 From: Haze Lee Date: Sun, 12 Oct 2025 15:59:42 +0900 Subject: [PATCH 1/4] feat(view): language detection for compose view - Using Natural Language framework. --- HackersPub/Views/ComposeView.swift | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/HackersPub/Views/ComposeView.swift b/HackersPub/Views/ComposeView.swift index 8b14e5f..8e45cca 100644 --- a/HackersPub/Views/ComposeView.swift +++ b/HackersPub/Views/ComposeView.swift @@ -1,6 +1,7 @@ import SwiftUI @preconcurrency import Apollo import Markdown +import NaturalLanguage struct ComposeView: View { @Environment(\.dismiss) private var dismiss @@ -91,6 +92,9 @@ struct ComposeView: View { TextEditor(text: $content) .font(.body) .opacity(content.isEmpty ? 0.25 : 1) + .onChange(of: content) { _, newValue in + detectAndUpdateLanguage(from: newValue) + } } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -165,6 +169,26 @@ struct ComposeView: View { return localeCode } + private func detectAndUpdateLanguage(from text: String) { + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + + // Get the dominant language + if let dominantLanguage = recognizer.dominantLanguage { + let languageCode = dominantLanguage.rawValue + + if availableLocales.contains(languageCode) { + lastSelectedLocale = languageCode + } else { + // Try to find a matching base language (e.g., "en" for "en-US") + let baseLanguage = String(languageCode.prefix(2)) + if availableLocales.contains(baseLanguage) { + lastSelectedLocale = baseLanguage + } + } + } + } + private func post() async { isPosting = true defer { isPosting = false } From 571a983902a80c19bde73090e5f0cd890b2410c1 Mon Sep 17 00:00:00 2001 From: Haze Lee Date: Sun, 12 Oct 2025 16:39:02 +0900 Subject: [PATCH 2/4] fix(view): enhance preview screen - add loading progress and animation - add placeholder view for preview with empty content --- HackersPub/Views/ComposeView.swift | 73 +++++++++++++++++----- HackersPub/Views/MarkdownPreviewView.swift | 63 +++++++++++++++++-- HackersPub/en.lproj/Localizable.strings | 2 + HackersPub/ko.lproj/Localizable.strings | 2 + 4 files changed, 118 insertions(+), 22 deletions(-) diff --git a/HackersPub/Views/ComposeView.swift b/HackersPub/Views/ComposeView.swift index 8e45cca..efe1e62 100644 --- a/HackersPub/Views/ComposeView.swift +++ b/HackersPub/Views/ComposeView.swift @@ -10,6 +10,7 @@ struct ComposeView: View { @State private var isPosting = false @State private var errorMessage: String? @State private var showPreview = false + @State private var isLoadingPreview = false @AppStorage("lastSelectedLocale") private var lastSelectedLocale: String = { Locale.current.language.languageCode?.identifier ?? "en" }() @@ -78,27 +79,65 @@ struct ComposeView: View { .padding(.top, 8) // Text editor or preview - if showPreview { - MarkdownPreviewView(html: htmlContent) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ZStack(alignment: .topLeading) { - if content.isEmpty { - Text(NSLocalizedString("compose.placeholder", comment: "Compose text placeholder")) - .foregroundStyle(.secondary) - .padding(.horizontal, 4) - .padding(.vertical, 8) + ZStack { + if showPreview { + ZStack { + if content.isEmpty { + // Empty state + VStack(spacing: 16) { + Image(systemName: "doc.text") + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + Text(NSLocalizedString("compose.preview.empty", comment: "Empty preview message")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // WebView with loading overlay + ZStack { + MarkdownPreviewView(html: htmlContent, isLoading: $isLoadingPreview) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .opacity(isLoadingPreview ? 0 : 1) + + if isLoadingPreview { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.2) + Text(NSLocalizedString("compose.preview.rendering", comment: "Rendering preview message")) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + } + } + } } - TextEditor(text: $content) - .font(.body) - .opacity(content.isEmpty ? 0.25 : 1) - .onChange(of: content) { _, newValue in - detectAndUpdateLanguage(from: newValue) + .transition(.opacity.combined(with: .scale(scale: 0.98))) + } else { + ZStack(alignment: .topLeading) { + if content.isEmpty { + Text(NSLocalizedString("compose.placeholder", comment: "Compose text placeholder")) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 8) + .allowsHitTesting(false) } + TextEditor(text: $content) + .font(.body) + .opacity(content.isEmpty ? 0.25 : 1) + .onChange(of: content) { _, newValue in + detectAndUpdateLanguage(from: newValue) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity.combined(with: .scale(scale: 0.98))) } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) } + .animation(.easeInOut(duration: 0.25), value: showPreview) + .animation(.easeInOut(duration: 0.25), value: isLoadingPreview) Divider() diff --git a/HackersPub/Views/MarkdownPreviewView.swift b/HackersPub/Views/MarkdownPreviewView.swift index 90b5410..c7895c3 100644 --- a/HackersPub/Views/MarkdownPreviewView.swift +++ b/HackersPub/Views/MarkdownPreviewView.swift @@ -4,6 +4,7 @@ import WebKit #if os(macOS) struct MarkdownPreviewView: NSViewRepresentable { let html: String + @Binding var isLoading: Bool func makeNSView(context: Context) -> WKWebView { let webView = WKWebView() @@ -13,14 +14,40 @@ struct MarkdownPreviewView: NSViewRepresentable { } func updateNSView(_ webView: WKWebView, context: Context) { - webView.loadHTMLString(html, baseURL: nil) + // Update the coordinator's binding reference + context.coordinator.isLoading = $isLoading + + // Only reload if HTML content has actually changed + if context.coordinator.lastHTML != html { + context.coordinator.lastHTML = html + webView.loadHTMLString(html, baseURL: nil) + } } func makeCoordinator() -> Coordinator { - Coordinator() + Coordinator(isLoading: $isLoading) } class Coordinator: NSObject, WKNavigationDelegate { + var isLoading: Binding + var lastHTML: String = "" + + init(isLoading: Binding) { + self.isLoading = isLoading + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + isLoading.wrappedValue = true + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + isLoading.wrappedValue = false + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + isLoading.wrappedValue = false + } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if navigationAction.navigationType == .linkActivated { if let url = navigationAction.request.url { @@ -36,6 +63,7 @@ struct MarkdownPreviewView: NSViewRepresentable { #else struct MarkdownPreviewView: UIViewRepresentable { let html: String + @Binding var isLoading: Bool @Environment(\.dynamicTypeSize) private var dynamicTypeSize func makeUIView(context: Context) -> WKWebView { @@ -47,15 +75,40 @@ struct MarkdownPreviewView: UIViewRepresentable { } func updateUIView(_ webView: WKWebView, context: Context) { - // The HTML is already wrapped with CSS that includes dynamic font sizing from ComposeView's htmlContent property - webView.loadHTMLString(html, baseURL: nil) + // Update the coordinator's binding reference + context.coordinator.isLoading = $isLoading + + // Only reload if HTML content has actually changed + if context.coordinator.lastHTML != html { + context.coordinator.lastHTML = html + webView.loadHTMLString(html, baseURL: nil) + } } func makeCoordinator() -> Coordinator { - Coordinator() + Coordinator(isLoading: $isLoading) } class Coordinator: NSObject, WKNavigationDelegate { + var isLoading: Binding + var lastHTML: String = "" + + init(isLoading: Binding) { + self.isLoading = isLoading + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + isLoading.wrappedValue = true + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + isLoading.wrappedValue = false + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + isLoading.wrappedValue = false + } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if navigationAction.navigationType == .linkActivated { if let url = navigationAction.request.url { diff --git a/HackersPub/en.lproj/Localizable.strings b/HackersPub/en.lproj/Localizable.strings index fa06b4e..b3235f5 100644 --- a/HackersPub/en.lproj/Localizable.strings +++ b/HackersPub/en.lproj/Localizable.strings @@ -59,6 +59,8 @@ "compose.error.failed" = "Failed to create post"; "compose.error.failedWithDetails" = "Failed to create post: %@"; "compose.error.ok" = "OK"; +"compose.preview.empty" = "Content is empty."; +"compose.preview.rendering" = "Loading Preview..."; /* Timeline */ "timeline.fediverse" = "Fediverse"; diff --git a/HackersPub/ko.lproj/Localizable.strings b/HackersPub/ko.lproj/Localizable.strings index ee7b540..6ce3c31 100644 --- a/HackersPub/ko.lproj/Localizable.strings +++ b/HackersPub/ko.lproj/Localizable.strings @@ -59,6 +59,8 @@ "compose.error.failed" = "게시물 작성 실패"; "compose.error.failedWithDetails" = "게시물 작성 실패: %@"; "compose.error.ok" = "확인"; +"compose.preview.empty" = "내용이 없습니다."; +"compose.preview.rendering" = "미리보기 로딩 중..."; /* Timeline */ "timeline.fediverse" = "페디버스"; From 8c8f99b48922ac98fae02a5af797fc5328cab316 Mon Sep 17 00:00:00 2001 From: Haze Lee Date: Sun, 12 Oct 2025 16:44:57 +0900 Subject: [PATCH 3/4] fix(view): following font settings in compose view --- HackersPub/Views/ComposeView.swift | 4 ++- HackersPub/Views/MarkdownPreviewView.swift | 34 +++++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/HackersPub/Views/ComposeView.swift b/HackersPub/Views/ComposeView.swift index efe1e62..aa9ac41 100644 --- a/HackersPub/Views/ComposeView.swift +++ b/HackersPub/Views/ComposeView.swift @@ -5,6 +5,7 @@ import NaturalLanguage struct ComposeView: View { @Environment(\.dismiss) private var dismiss + @ObservedObject private var fontSettings = FontSettingsManager.shared @State private var content: String = "" @State private var visibility: GraphQLEnum = .case(.public) @State private var isPosting = false @@ -119,13 +120,14 @@ struct ComposeView: View { ZStack(alignment: .topLeading) { if content.isEmpty { Text(NSLocalizedString("compose.placeholder", comment: "Compose text placeholder")) + .font(fontSettings.font(for: .body)) .foregroundStyle(.secondary) .padding(.horizontal, 4) .padding(.vertical, 8) .allowsHitTesting(false) } TextEditor(text: $content) - .font(.body) + .font(fontSettings.font(for: .body)) .opacity(content.isEmpty ? 0.25 : 1) .onChange(of: content) { _, newValue in detectAndUpdateLanguage(from: newValue) diff --git a/HackersPub/Views/MarkdownPreviewView.swift b/HackersPub/Views/MarkdownPreviewView.swift index c7895c3..2093089 100644 --- a/HackersPub/Views/MarkdownPreviewView.swift +++ b/HackersPub/Views/MarkdownPreviewView.swift @@ -1,10 +1,23 @@ import SwiftUI import WebKit +struct FontSettingsSnapshot: Equatable { + let fontName: String + let sizeMultiplier: Double + let useSystemDynamicType: Bool + + init(from manager: FontSettingsManager) { + self.fontName = manager.selectedFontName + self.sizeMultiplier = manager.fontSizeMultiplier + self.useSystemDynamicType = manager.useSystemDynamicType + } +} + #if os(macOS) struct MarkdownPreviewView: NSViewRepresentable { let html: String @Binding var isLoading: Bool + @ObservedObject private var fontSettings = FontSettingsManager.shared func makeNSView(context: Context) -> WKWebView { let webView = WKWebView() @@ -17,9 +30,14 @@ struct MarkdownPreviewView: NSViewRepresentable { // Update the coordinator's binding reference context.coordinator.isLoading = $isLoading - // Only reload if HTML content has actually changed - if context.coordinator.lastHTML != html { + // Check if HTML or font settings have changed + let currentFontSettings = FontSettingsSnapshot(from: fontSettings) + let contentChanged = context.coordinator.lastHTML != html + let fontChanged = context.coordinator.lastFontSettings != currentFontSettings + + if contentChanged || fontChanged { context.coordinator.lastHTML = html + context.coordinator.lastFontSettings = currentFontSettings webView.loadHTMLString(html, baseURL: nil) } } @@ -31,6 +49,7 @@ struct MarkdownPreviewView: NSViewRepresentable { class Coordinator: NSObject, WKNavigationDelegate { var isLoading: Binding var lastHTML: String = "" + var lastFontSettings: FontSettingsSnapshot? init(isLoading: Binding) { self.isLoading = isLoading @@ -64,6 +83,7 @@ struct MarkdownPreviewView: NSViewRepresentable { struct MarkdownPreviewView: UIViewRepresentable { let html: String @Binding var isLoading: Bool + @ObservedObject private var fontSettings = FontSettingsManager.shared @Environment(\.dynamicTypeSize) private var dynamicTypeSize func makeUIView(context: Context) -> WKWebView { @@ -78,9 +98,14 @@ struct MarkdownPreviewView: UIViewRepresentable { // Update the coordinator's binding reference context.coordinator.isLoading = $isLoading - // Only reload if HTML content has actually changed - if context.coordinator.lastHTML != html { + // Check if HTML or font settings have changed + let currentFontSettings = FontSettingsSnapshot(from: fontSettings) + let contentChanged = context.coordinator.lastHTML != html + let fontChanged = context.coordinator.lastFontSettings != currentFontSettings + + if contentChanged || fontChanged { context.coordinator.lastHTML = html + context.coordinator.lastFontSettings = currentFontSettings webView.loadHTMLString(html, baseURL: nil) } } @@ -92,6 +117,7 @@ struct MarkdownPreviewView: UIViewRepresentable { class Coordinator: NSObject, WKNavigationDelegate { var isLoading: Binding var lastHTML: String = "" + var lastFontSettings: FontSettingsSnapshot? init(isLoading: Binding) { self.isLoading = isLoading From 99a049760cd8628ddd36a0064559c015370f2a93 Mon Sep 17 00:00:00 2001 From: Haze Lee Date: Sun, 12 Oct 2025 16:48:30 +0900 Subject: [PATCH 4/4] fix(view): remove dynamic type toggle for macOS - remove configuration toggle, because macOS doesn't support dynamic type --- HackersPub/Views/SettingsView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/HackersPub/Views/SettingsView.swift b/HackersPub/Views/SettingsView.swift index 9563335..8cef5a7 100644 --- a/HackersPub/Views/SettingsView.swift +++ b/HackersPub/Views/SettingsView.swift @@ -85,11 +85,17 @@ struct SettingsView: View { } Slider(value: SliderHelper.snappedBinding($fontSettings.fontSizeMultiplier, step: 0.05, range: 0.75...3.0), in: 0.75...3.0, step: 0.05) +#if os(iOS) .disabled(fontSettings.useSystemDynamicType) +#endif } +#if os(iOS) .opacity(fontSettings.useSystemDynamicType ? 0.5 : 1.0) +#endif +#if os(iOS) Toggle(NSLocalizedString("settings.typography.useSystemDynamicType", comment: "Use system dynamic type toggle"), isOn: $fontSettings.useSystemDynamicType) +#endif Button(NSLocalizedString("settings.typography.resetToDefaults", comment: "Reset to defaults button")) { fontSettings.resetToDefaults()