diff --git a/HackersPub/Views/ComposeView.swift b/HackersPub/Views/ComposeView.swift index 8b14e5f..aa9ac41 100644 --- a/HackersPub/Views/ComposeView.swift +++ b/HackersPub/Views/ComposeView.swift @@ -1,14 +1,17 @@ import SwiftUI @preconcurrency import Apollo import Markdown +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 @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" }() @@ -77,24 +80,66 @@ 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) + .transition(.opacity.combined(with: .scale(scale: 0.98))) + } else { + 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(fontSettings.font(for: .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() @@ -165,6 +210,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 } diff --git a/HackersPub/Views/MarkdownPreviewView.swift b/HackersPub/Views/MarkdownPreviewView.swift index 90b5410..2093089 100644 --- a/HackersPub/Views/MarkdownPreviewView.swift +++ b/HackersPub/Views/MarkdownPreviewView.swift @@ -1,9 +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() @@ -13,14 +27,46 @@ struct MarkdownPreviewView: NSViewRepresentable { } func updateNSView(_ webView: WKWebView, context: Context) { - webView.loadHTMLString(html, baseURL: nil) + // Update the coordinator's binding reference + context.coordinator.isLoading = $isLoading + + // 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) + } } func makeCoordinator() -> Coordinator { - Coordinator() + Coordinator(isLoading: $isLoading) } class Coordinator: NSObject, WKNavigationDelegate { + var isLoading: Binding + var lastHTML: String = "" + var lastFontSettings: FontSettingsSnapshot? + + 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 +82,8 @@ struct MarkdownPreviewView: NSViewRepresentable { #else 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 { @@ -47,15 +95,46 @@ 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 + + // 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) + } } func makeCoordinator() -> Coordinator { - Coordinator() + Coordinator(isLoading: $isLoading) } class Coordinator: NSObject, WKNavigationDelegate { + var isLoading: Binding + var lastHTML: String = "" + var lastFontSettings: FontSettingsSnapshot? + + 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/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() 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" = "페디버스";