diff --git a/apps/ios/EdgeEver.xcodeproj/project.pbxproj b/apps/ios/EdgeEver.xcodeproj/project.pbxproj index 46eee51b..37f02957 100644 --- a/apps/ios/EdgeEver.xcodeproj/project.pbxproj +++ b/apps/ios/EdgeEver.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 4BA97CD264D0929B20ED19DB /* TipTapWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12DCA98C14CC6F28C22C41A1 /* TipTapWebView.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 */; }; 506702F1751945424AC4CB98 /* MobileUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A375D81573943A98C5732F81 /* MobileUI.swift */; }; 50800DF91A760DE7FB68DFB4 /* Motion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C065FED3C78404D4D8DD19 /* Motion.swift */; }; 511A8DCB204AFCB2880AE51E /* MemoCreateCommitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E057D0906932A00DF62EBB /* MemoCreateCommitTests.swift */; }; @@ -48,6 +49,7 @@ 63D4B562B4EF6ED7291155F6 /* TemplatePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BA3D6859C4C6D9F65435671 /* TemplatePickerSheet.swift */; }; 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 */; }; 7BDAD91E69B7B62D7D390CB5 /* MemoEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3674473015F8EB6863B69B0F /* MemoEditView.swift */; }; 8430B1EDE6CA87A0C922E484 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D94BB61519CA2A345D99C1 /* SettingsView.swift */; }; 8785D0133655387F399C3C58 /* LocalMirrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2035E878FD92B74D889D6047 /* LocalMirrorTests.swift */; }; @@ -120,6 +122,7 @@ 0531FC6AA81DAF0A55B42362 /* AppDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = ""; }; 12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncProtocol.swift; sourceTree = ""; }; 12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipTapWebView.swift; sourceTree = ""; }; + 13105CFE9131425A1C7E4BF0 /* WebClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebClipper.swift; sourceTree = ""; }; 1606EF8ADEA4921421EB0690 /* MobileUIParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileUIParityTests.swift; sourceTree = ""; }; 17A2092411EFC3765B97DF88 /* SystemImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImagePicker.swift; sourceTree = ""; }; 1CD75D20D94A27E982103AB3 /* EdgeEver.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = EdgeEver.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -148,6 +151,7 @@ 58C065FED3C78404D4D8DD19 /* Motion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Motion.swift; sourceTree = ""; }; 59C38DBB41BA706C678AB9BD /* MemoEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoEditViewModel.swift; sourceTree = ""; }; 5CB54673617E14F28B7608BD /* Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typography.swift; sourceTree = ""; }; + 5D7D3262D1E3E40E38361E84 /* WebClipperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebClipperTests.swift; sourceTree = ""; }; 62B460B6BFE4CFB23073C7E1 /* SyncOutboxRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncOutboxRepository.swift; sourceTree = ""; }; 6CB16CA1499554463E1927E7 /* PreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesStore.swift; sourceTree = ""; }; 73FA569C71080B6F9989AA03 /* ApiTokensView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiTokensView.swift; sourceTree = ""; }; @@ -243,6 +247,7 @@ isa = PBXGroup; children = ( D045B340A984E17B3B0047BF /* ShareHandoffStore.swift */, + 13105CFE9131425A1C7E4BF0 /* WebClipper.swift */, ); path = Share; sourceTree = ""; @@ -483,6 +488,7 @@ E6159DBF27CC86920005E25D /* SyncProtocolTests.swift */, 8A536119D29812EF11DAEAA8 /* TipTapContentSourceTests.swift */, 41B15501968A29C372E7B3BB /* URLNormalizeTests.swift */, + 5D7D3262D1E3E40E38361E84 /* WebClipperTests.swift */, ); path = EdgeEverTests; sourceTree = ""; @@ -635,6 +641,7 @@ 1F8E6268C9DA330CA236B36F /* SyncProtocolTests.swift in Sources */, 4EC901852EDA4BC001717747 /* TipTapContentSourceTests.swift in Sources */, 0D67F968CAFCC17F28A5555C /* URLNormalizeTests.swift in Sources */, + 6E1459D0AA2E0217C517F973 /* WebClipperTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -696,6 +703,7 @@ 6A671BB702DA77A0548E6E29 /* URL+Normalize.swift in Sources */, E7B888711C3A5403083864B3 /* UsersManagementView.swift in Sources */, B79E88A198C6635419CAE9E5 /* WKWebView+ProgrammaticKeyboard.swift in Sources */, + 4F49C2894D3842B4DEAFAD5E /* WebClipper.swift in Sources */, 38640D9CE35D118F325AABC5 /* WorkspaceStore.swift in Sources */, 487717BFBCCF86B783B33B44 /* WorkspaceView.swift in Sources */, ); diff --git a/apps/ios/EdgeEver/Editor/ResourceActions.swift b/apps/ios/EdgeEver/Editor/ResourceActions.swift index 6c45071e..0e907ba0 100644 --- a/apps/ios/EdgeEver/Editor/ResourceActions.swift +++ b/apps/ios/EdgeEver/Editor/ResourceActions.swift @@ -764,7 +764,7 @@ private enum ResourceActionError: LocalizedError { } /// System share sheet with completion so the user always gets feedback. -private struct ActivityShareView: UIViewControllerRepresentable { +struct ActivityShareView: UIViewControllerRepresentable { let items: [Any] var onComplete: (_ completed: Bool, _ activityType: UIActivity.ActivityType?, _ error: Error?) -> Void diff --git a/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift b/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift index 9d92743e..e2035cba 100644 --- a/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift +++ b/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift @@ -9,9 +9,14 @@ struct TipTapSession { var markdown: String var baseURL: URL? var token: String? + var locale: String + var theme: String + var placeholder: String var onChange: ((String, String) -> Void)? var onResourcePress: ((ResourceTarget) -> Void)? var onImagePreview: ((_ source: String, _ alt: String) -> Void)? + var onPickImage: (() -> Void)? + var onSearchResult: ((_ count: Int, _ index: Int) -> Void)? var onBodyReady: (() -> Void)? } @@ -38,7 +43,7 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD private var ready = false private var lastPushedJSON: String? private var lastEditorEmittedFingerprint: String? - private var lastAppliedMode: String? + private var lastAppliedConfiguration: String? private var hydrateGeneration: UInt64 = 0 private var bodyReadyGeneration: UInt64 = 0 private var contentGeneration: UInt64 = 0 @@ -62,7 +67,8 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD config.setURLSchemeHandler(handler, forURLScheme: EdgeEverResourceSchemeHandler.scheme) let wv = WKWebView(frame: .zero, configuration: config) wv.isOpaque = false - wv.backgroundColor = .white + wv.backgroundColor = .clear + wv.scrollView.backgroundColor = .clear wv.scrollView.clipsToBounds = true wv.scrollView.contentInsetAdjustmentBehavior = .never wv.scrollView.delaysContentTouches = false @@ -132,6 +138,8 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD s.onChange = nil s.onResourcePress = nil s.onImagePreview = nil + s.onPickImage = nil + s.onSearchResult = nil s.onBodyReady = nil session = s } @@ -175,6 +183,28 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD focusEnd(attempt: 0) } + /// Select a match inside the current viewer/editor and return the total + active index. + func search(_ query: String, index: Int) { + guard ready else { + session?.onSearchResult?(0, 0) + return + } + let queryB64 = Data(query.utf8).base64EncodedString() + let js = """ + (function(){ + try { + if (!window.EdgeEverEditor || !window.EdgeEverEditor.search) return false; + var bin = atob('\(queryB64)'); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + window.EdgeEverEditor.search(new TextDecoder('utf-8').decode(bytes), \(max(0, index))); + return true; + } catch (e) { return false; } + })(); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + } + private func focusEnd(attempt: Int) { guard session?.mode == .editor else { return } // fullScreenCover animation / re-parent: wait until we are in a window. @@ -365,7 +395,7 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { ready = true - lastAppliedMode = nil + lastAppliedConfiguration = nil applyMode() pushContentIfNeeded(force: true) } @@ -375,12 +405,24 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD private func applyMode() { guard ready, let session else { return } let mode = session.mode.rawValue - if lastAppliedMode == mode { return } - lastAppliedMode = mode + let locale = session.locale == "en-US" ? "en-US" : "zh-CN" + let theme = session.theme == "dark" ? "dark" : "light" + let configuration = "\(mode)|\(locale)|\(theme)|\(session.placeholder)" + if lastAppliedConfiguration == configuration { return } + lastAppliedConfiguration = configuration + let options: [String: String] = [ + "mode": mode, + "locale": locale, + "theme": theme, + "placeholder": session.placeholder, + ] + guard let data = try? JSONSerialization.data(withJSONObject: options), + let json = String(data: data, encoding: .utf8) + else { return } let js = """ (function(){ if (!window.EdgeEverEditor) return; - window.EdgeEverEditor.configure({ mode: '\(mode)', locale: 'zh-CN', theme: 'light' }); + window.EdgeEverEditor.configure(\(json)); })(); """ webView.evaluateJavaScript(js, completionHandler: nil) @@ -412,9 +454,7 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD let shouldFocusAfterPush = session.mode == .editor && focusedGeneration != gen - if lastAppliedMode != session.mode.rawValue { - applyMode() - } + applyMode() let fn = useJSON ? "setDocumentFromJSON" : "setMarkdown" let payload = decision.payload @@ -531,7 +571,7 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD switch type { case "ready": ready = true - lastAppliedMode = nil + lastAppliedConfiguration = nil pushContentIfNeeded(force: true) case "change": // Host dismantled (create/edit dismissed) — drop late events so they cannot @@ -563,6 +603,14 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD guard !source.isEmpty else { break } let cb = session?.onImagePreview DispatchQueue.main.async { cb?(source, alt) } + case "pickImage": + let cb = session?.onPickImage + DispatchQueue.main.async { cb?() } + case "searchResult": + let count = (body["count"] as? NSNumber)?.intValue ?? 0 + let index = (body["index"] as? NSNumber)?.intValue ?? 0 + let cb = session?.onSearchResult + DispatchQueue.main.async { cb?(count, index) } default: break } diff --git a/apps/ios/EdgeEver/Editor/TipTapWebView.swift b/apps/ios/EdgeEver/Editor/TipTapWebView.swift index 09f07b4c..c574777f 100644 --- a/apps/ios/EdgeEver/Editor/TipTapWebView.swift +++ b/apps/ios/EdgeEver/Editor/TipTapWebView.swift @@ -15,9 +15,14 @@ struct TipTapWebView: UIViewRepresentable { let markdown: String let baseURL: URL? let token: String? + let locale: String + let theme: String + let placeholder: String let onChange: ((String, String) -> Void)? var onResourcePress: ((ResourceTarget) -> Void)? = nil var onImagePreview: ((_ source: String, _ alt: String) -> Void)? = nil + var onPickImage: (() -> Void)? = nil + var onSearchResult: ((_ count: Int, _ index: Int) -> Void)? = nil var onBodyReady: (() -> Void)? = nil func makeCoordinator() -> Coordinator { @@ -26,7 +31,7 @@ struct TipTapWebView: UIViewRepresentable { func makeUIView(context: Context) -> TipTapHostView { let host = TipTapHostView() - host.backgroundColor = .white + host.backgroundColor = .clear host.clipsToBounds = true return host } @@ -42,9 +47,14 @@ struct TipTapWebView: UIViewRepresentable { markdown: markdown, baseURL: baseURL, token: token, + locale: locale, + theme: theme, + placeholder: placeholder, onChange: onChange, onResourcePress: onResourcePress, onImagePreview: onImagePreview, + onPickImage: onPickImage, + onSearchResult: onSearchResult, onBodyReady: onBodyReady ) ) diff --git a/apps/ios/EdgeEver/Features/Share/WebClipper.swift b/apps/ios/EdgeEver/Features/Share/WebClipper.swift new file mode 100644 index 00000000..1d08f73b --- /dev/null +++ b/apps/ios/EdgeEver/Features/Share/WebClipper.swift @@ -0,0 +1,339 @@ +import Foundation +import SwiftUI +import WebKit + +struct WebClipDraft: Equatable, Sendable { + var title: String + var contentMarkdown: String + var tagsText: String + + var createSeed: CreateMemoSeed { + CreateMemoSeed(title: title, contentMarkdown: contentMarkdown, tagsText: tagsText) + } +} + +struct RenderedWebPage: Equatable, Sendable { + var title: String + var contentHTML: String + var finalURL: String +} + +enum WebClipper { + static func sharedWebURL(from payloads: [ShareHandoffStore.SharePayload]) -> URL? { + for payload in payloads { + for candidate in [payload.url, payload.text].compactMap({ $0 }) { + guard let range = candidate.range(of: #"https?://[^\s<>\"'))]+"#, options: .regularExpression), + let url = URL(string: String(candidate[range])), + url.scheme == "http" || url.scheme == "https" + else { continue } + return url + } + } + return nil + } + + static func isWeChatArticle(_ url: URL) -> Bool { + url.scheme?.lowercased() == "https" + && url.host?.lowercased() == "mp.weixin.qq.com" + && url.path.hasPrefix("/s") + } + + static func build(_ sourceURL: URL, capturedAt: Date = Date()) async -> WebClipDraft { + var request = URLRequest(url: sourceURL) + request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, + (200 ..< 300).contains(http.statusCode), + let html = String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .isoLatin1) + else { return fallback(sourceURL, capturedAt: capturedAt) } + let finalURL = response.url ?? sourceURL + let title = extractTitle(html) ?? hostnameTitle(sourceURL) + let bodyHTML = firstInnerHTML(html, tags: ["article", "main"]) + return buildRendered( + sourceURL, + page: RenderedWebPage(title: title, contentHTML: bodyHTML, finalURL: finalURL.absoluteString), + capturedAt: capturedAt + ) + } catch { + return fallback(sourceURL, capturedAt: capturedAt) + } + } + + static func buildRendered( + _ sourceURL: URL, + page: RenderedWebPage, + capturedAt: Date = Date() + ) -> WebClipDraft { + let title = normalizedText(page.title).nilIfEmpty ?? hostnameTitle(sourceURL) + let markdown = htmlToMarkdown(page.contentHTML, baseURL: URL(string: page.finalURL) ?? sourceURL) + guard !markdown.isEmpty else { + var draft = fallback(sourceURL, capturedAt: capturedAt) + draft.title = title + return draft + } + return WebClipDraft( + title: title, + contentMarkdown: [ + "来源:[\(escapeMarkdown(sourceURL.absoluteString))](\(sourceURL.absoluteString))", + "剪藏时间:\(ISO8601DateFormatter.edgeEver.string(from: capturedAt))", + "---", + markdown, + ].joined(separator: "\n\n"), + tagsText: isWeChatArticle(sourceURL) ? "web-clip, wechat" : "web-clip" + ) + } + + static func fallback(_ sourceURL: URL, capturedAt: Date = Date()) -> WebClipDraft { + WebClipDraft( + title: hostnameTitle(sourceURL), + contentMarkdown: [ + "来源:[\(escapeMarkdown(sourceURL.absoluteString))](\(sourceURL.absoluteString))", + "剪藏时间:\(ISO8601DateFormatter.edgeEver.string(from: capturedAt))", + "正文暂时无法抓取,来源链接已保留,可稍后重试。", + ].joined(separator: "\n\n"), + tagsText: isWeChatArticle(sourceURL) ? "web-clip, wechat" : "web-clip" + ) + } + + static func htmlToMarkdown(_ html: String, baseURL: URL) -> String { + guard !html.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "" } + var value = replace(html, pattern: #""#) { _ in "" } + value = replace( + value, + pattern: #"<(script|style|noscript|template|iframe|svg|canvas)\b[^>]*>[\s\S]*?"# + ) { _ in "" } + value = replace(value, pattern: #"]*>"#) { match in + let source = attribute(match, "data-src").nilIfEmpty + ?? attribute(match, "data-original").nilIfEmpty + ?? attribute(match, "src").nilIfEmpty + guard let source, !source.hasPrefix("data:"), let resolved = resolve(source, against: baseURL) else { return "" } + let alt = normalizedText(attribute(match, "alt")).nilIfEmpty ?? "图片" + return "\n\n![\(escapeMarkdown(alt))](\(resolved))\n\n" + } + value = replace(value, pattern: #"]*>([\s\S]*?)"#) { match in + let href = attribute(match, "href") + let label = normalizedText(stripTags(capture(match, pattern: #"]*>([\s\S]*?)"#) ?? "")) + guard !href.isEmpty, !href.lowercased().hasPrefix("javascript:"), + let resolved = resolve(href, against: baseURL) + else { return label } + return "[\(escapeMarkdown(label.isEmpty ? resolved : label))](\(resolved))" + } + value = replace(value, pattern: #"]*>"#) { match in + let level = Int(capture(match, pattern: #""#, "\n\n"), (#""#, "\n"), + (#"]*>"#, "\n\n---\n\n"), (#"]*>"#, "\n- "), + (#""#, "\n"), (#"<(strong|b)\b[^>]*>"#, "**"), + (#""#, "**"), (#"<(em|i)\b[^>]*>"#, "*"), + (#""#, "*"), (#"]*>"#, "`"), + (#""#, "`"), (#"]*>"#, "\n\n> "), + (#"]*>"#, "\n\n"), + (#"]*>"#, ""), + ] + for (pattern, replacement) in substitutions { + value = replace(value, pattern: pattern) { _ in replacement } + } + return decodeEntities(value) + .replacingOccurrences(of: "\r", with: "") + .replacingOccurrences(of: #"[ \t]+\n"#, with: "\n", options: .regularExpression) + .replacingOccurrences(of: #"\n[ \t]+"#, with: "\n", options: .regularExpression) + .replacingOccurrences(of: #"[ \t]{2,}"#, with: " ", options: .regularExpression) + .replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func extractTitle(_ html: String) -> String? { + let metas = matches(html, pattern: #"]*>"#) + for key in ["og:title", "twitter:title"] { + if let tag = metas.first(where: { + (attribute($0, "property").nilIfEmpty ?? attribute($0, "name")).lowercased() == key + }), let title = normalizedText(attribute(tag, "content")).nilIfEmpty { return title } + } + guard let title = capture(html, pattern: #"]*>([\s\S]*?)"#) else { return nil } + return normalizedText(stripTags(title)).nilIfEmpty + } + + private static func firstInnerHTML(_ html: String, tags: [String]) -> String { + for tag in tags { + if let value = capture(html, pattern: "<\(tag)\\b[^>]*>([\\s\\S]*?)") { return value } + } + return "" + } + + private static func hostnameTitle(_ url: URL) -> String { url.host ?? "网页剪藏" } + private static func normalizedText(_ value: String) -> String { + decodeEntities(value).replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + private static func stripTags(_ value: String) -> String { + value.replacingOccurrences(of: #"<[^>]*>"#, with: " ", options: .regularExpression) + } + private static func escapeMarkdown(_ value: String) -> String { + value.replacingOccurrences(of: #"([\[\]])"#, with: #"\\$1"#, options: .regularExpression) + } + private static func resolve(_ value: String, against baseURL: URL) -> String? { + guard let url = URL(string: value, relativeTo: baseURL)?.absoluteURL, + url.scheme == "http" || url.scheme == "https" else { return nil } + return url.absoluteString + } + private static func attribute(_ tag: String, _ name: String) -> String { + let pattern = "(?:^|\\s)\(NSRegularExpression.escapedPattern(for: name))\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))" + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: tag, range: NSRange(tag.startIndex..., in: tag)) else { return "" } + for index in 1 ..< match.numberOfRanges where match.range(at: index).location != NSNotFound { + if let range = Range(match.range(at: index), in: tag) { return decodeEntities(String(tag[range])) } + } + return "" + } + private static func capture(_ value: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)), + match.numberOfRanges > 1, let range = Range(match.range(at: 1), in: value) else { return nil } + return String(value[range]) + } + private static func matches(_ value: String, pattern: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return [] } + return regex.matches(in: value, range: NSRange(value.startIndex..., in: value)).compactMap { + Range($0.range, in: value).map { String(value[$0]) } + } + } + private static func replace(_ value: String, pattern: String, transform: (String) -> String) -> String { + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return value } + var output = value + for match in regex.matches(in: value, range: NSRange(value.startIndex..., in: value)).reversed() { + guard let originalRange = Range(match.range, in: value), let outputRange = Range(match.range, in: output) else { continue } + output.replaceSubrange(outputRange, with: transform(String(value[originalRange]))) + } + return output + } + private static func decodeEntities(_ value: String) -> String { + let named = ["amp": "&", "apos": "'", "gt": ">", "hellip": "…", "ldquo": "“", "lsquo": "‘", "lt": "<", "middot": "·", "nbsp": " ", "ndash": "–", "quot": "\"", "rdquo": "”", "rsquo": "’"] + return replace(value, pattern: #"&(#x?[0-9a-f]+|[a-z][a-z0-9]+);?"#) { entity in + let key = String(entity.dropFirst().dropLast(entity.hasSuffix(";") ? 1 : 0)) + if key.lowercased().hasPrefix("#x"), let scalar = UInt32(key.dropFirst(2), radix: 16).flatMap(UnicodeScalar.init) { return String(scalar) } + if key.hasPrefix("#"), let scalar = UInt32(key.dropFirst()).flatMap(UnicodeScalar.init) { return String(scalar) } + return named[key.lowercased()] ?? entity + } + } +} + +/// Hidden rendered-page capture used for WeChat pages that server-side fetch cannot reliably read. +struct WebClipCaptureView: UIViewRepresentable { + let url: URL + let onCaptured: (RenderedWebPage) -> Void + let onFailed: (String) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(onCaptured: onCaptured, onFailed: onFailed) } + + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + configuration.userContentController.add(context.coordinator, name: "webClip") + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = context.coordinator + view.isOpaque = false + view.backgroundColor = .clear + return view + } + + func updateUIView(_ view: WKWebView, context: Context) { + context.coordinator.onCaptured = onCaptured + context.coordinator.onFailed = onFailed + guard context.coordinator.loadedURL != url else { return } + context.coordinator.loadedURL = url + view.load(URLRequest(url: url)) + context.coordinator.startTimeout() + } + + static func dismantleUIView(_ view: WKWebView, coordinator: Coordinator) { + coordinator.timeout?.cancel() + view.stopLoading() + view.configuration.userContentController.removeScriptMessageHandler(forName: "webClip") + } + + final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var onCaptured: (RenderedWebPage) -> Void + var onFailed: (String) -> Void + var loadedURL: URL? + var timeout: DispatchWorkItem? + private var completed = false + + init(onCaptured: @escaping (RenderedWebPage) -> Void, onFailed: @escaping (String) -> Void) { + self.onCaptured = onCaptured + self.onFailed = onFailed + } + + func startTimeout() { + completed = false + timeout?.cancel() + let work = DispatchWorkItem { [weak self] in self?.finishFailure("微信文章加载超时。") } + timeout = work + DispatchQueue.main.asyncAfter(deadline: .now() + 20, execute: work) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.evaluateJavaScript(Self.captureScript, completionHandler: nil) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + finishFailure(error.localizedDescription) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + finishFailure(error.localizedDescription) + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard !completed, let body = message.body as? [String: Any], let status = body["status"] as? String else { return } + if status == "captured", let html = body["contentHtml"] as? String, !html.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + completed = true + timeout?.cancel() + onCaptured(RenderedWebPage( + title: body["title"] as? String ?? "", + contentHTML: html, + finalURL: body["finalUrl"] as? String ?? loadedURL?.absoluteString ?? "" + )) + } else { + finishFailure(body["message"] as? String ?? "没有找到可剪藏的正文。") + } + } + + private func finishFailure(_ message: String) { + guard !completed else { return } + completed = true + timeout?.cancel() + onFailed(message) + } + + private static let captureScript = #""" + (function(){ + var attempts=0, completed=false; + function send(value){ if(completed)return; completed=true; window.webkit.messageHandlers.webClip.postMessage(value); } + function text(selector){ var el=document.querySelector(selector); return el&&el.textContent?el.textContent.trim():""; } + function capture(){ + var content=document.getElementById("js_content")||document.querySelector("article")||document.querySelector("main"); + if(content && content.textContent && content.textContent.trim()){ + var meta=document.querySelector('meta[property="og:title"],meta[name="twitter:title"]'); + send({status:"captured",title:(meta&&meta.getAttribute("content"))||text("#activity-name")||text(".rich_media_title")||document.title||"",contentHtml:content.innerHTML,finalUrl:location.href}); return; + } + var error=text(".weui-msg__title")||text(".weui-msg__desc"); + if(error){send({status:"failed",message:error});return;} + attempts++; if(attempts>=50){send({status:"failed",message:"页面加载完成,但没有找到可剪藏的正文。"});return;} + setTimeout(capture,250); + } + capture(); + })(); + """# + } +} + +private extension String { + var nilIfEmpty: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/ConflictResolutionView.swift b/apps/ios/EdgeEver/Features/Workspace/ConflictResolutionView.swift index 2a20d48d..24e78c82 100644 --- a/apps/ios/EdgeEver/Features/Workspace/ConflictResolutionView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/ConflictResolutionView.swift @@ -11,8 +11,11 @@ struct ConflictResolutionView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("同步冲突").font(.headline) - Text("本地草稿与服务器版本不一致。你可以丢弃本地更改并采用云端内容,或稍后再处理。") + Text(env.preferences.t("同步冲突", en: "Sync conflict")).font(.headline) + Text(env.preferences.t( + "本地草稿与服务器版本不一致。你可以丢弃本地更改并采用云端内容,或稍后再处理。", + en: "The local draft differs from the server version. Discard local changes and use the cloud version, or resolve it later." + )) .font(.subheadline) .foregroundStyle(.secondary) if let err = item.lastError { @@ -22,13 +25,13 @@ struct ConflictResolutionView: View { Text(error).font(.caption).foregroundStyle(.red) } HStack { - Button("丢弃本地") { + Button(env.preferences.t("丢弃本地", en: "Discard local")) { Task { await discardLocal() } } .buttonStyle(.borderedProminent) .tint(.red) .disabled(busy) - Button("稍后") { onResolved() } + Button(env.preferences.t("稍后", en: "Later")) { onResolved() } .disabled(busy) } } diff --git a/apps/ios/EdgeEver/Features/Workspace/ListActionsSheet.swift b/apps/ios/EdgeEver/Features/Workspace/ListActionsSheet.swift index a5cb8189..c262f2af 100644 --- a/apps/ios/EdgeEver/Features/Workspace/ListActionsSheet.swift +++ b/apps/ios/EdgeEver/Features/Workspace/ListActionsSheet.swift @@ -6,7 +6,7 @@ struct ListActionsSheet: View { @Bindable var store: WorkspaceStore @Environment(\.dismiss) private var dismiss - private var listTitle: String { store.titleLabel } + private var listTitle: String { store.activeNotebook?.name ?? env.preferences.t("全部笔记", en: "All notes") } private var listDescription: String { env.preferences.t("\(store.totalCount) 条笔记", en: "\(store.totalCount) notes") } diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift b/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift index 2c0ca0ed..fe319e9f 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift @@ -29,6 +29,7 @@ enum MemoCreateCommit { expectedContentHash: String?, notebookId: String, title: String, + untitledTitle: String = "无标题笔记", contentMarkdown: String, contentJSON: String?, tags: [String], @@ -40,7 +41,7 @@ enum MemoCreateCommit { guard !notebookId.isEmpty else { throw APIError(status: 400, code: nil, message: "请选择笔记本") } - let displayTitle = title.isEmpty ? "无标题笔记" : title + let displayTitle = title.isEmpty ? untitledTitle : title if isMaterializedServerId(memoId), let serverId = memoId { guard var memo = try mirror.resolveMemo(scope: scope, id: serverId) else { diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift index d4107ffc..5f267de6 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift @@ -5,14 +5,14 @@ import Pow struct MemoDetailView: View { @Environment(AppEnvironment.self) private var env @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme let memoId: String /// Present editor from the parent `WorkspaceView` (more reliable than cover on a pushed page). var onEdit: (String) -> Void = { _ in } @State private var memo: MemoDetail? @State private var showRevisions = false - @State private var showShareAlert = false - @State private var shareURL: String? + @State private var memoSharePayload: MemoSharePayload? @State private var error: String? @State private var conflictItem: OutboxItem? @State private var outboxStatus: OutboxStatus? @@ -20,6 +20,8 @@ struct MemoDetailView: View { @State private var pinPulse = false @State private var searchOpen = false @State private var searchQuery = "" + @State private var searchMatchCount = 0 + @State private var searchMatchIndex = 0 @State private var showDeleteConfirm = false @State private var showMoreMenu = false @State private var resourceTarget: ResourceTarget? @@ -94,6 +96,12 @@ struct MemoDetailView: View { .presentationDetents([.height(360), .medium]) .presentationDragIndicator(.hidden) } + .sheet(item: $memoSharePayload) { payload in + ActivityShareView(items: [payload.message, payload.url]) { _, _, error in + if let error { self.error = error.localizedDescription } + memoSharePayload = nil + } + } .fullScreenCover(isPresented: Binding( get: { imagePreview != nil }, set: { if !$0 { imagePreview = nil } } @@ -150,16 +158,6 @@ struct MemoDetailView: View { } message: { Text(env.preferences.t("笔记将移入回收站。", en: "The note will move to trash.")) } - .alert(env.preferences.t("分享链接", en: "Share link"), isPresented: $showShareAlert) { - Button(env.preferences.t("复制", en: "Copy")) { - if let shareURL { - UIPasteboard.general.string = shareURL - } - } - Button(env.preferences.t("关闭", en: "Close"), role: .cancel) {} - } message: { - Text(shareURL ?? "") - } // Local SQLite mirror is sync and cheap — load before the first blank ProgressView frame. .onAppear { if memo == nil { @@ -180,6 +178,9 @@ struct MemoDetailView: View { } .onChange(of: memoId) { _, _ in bodyReady = false + searchQuery = "" + searchMatchCount = 0 + searchMatchIndex = 0 load() refreshSyncStatus() } @@ -244,7 +245,13 @@ struct MemoDetailView: View { label: env.preferences.t("搜索当前笔记", en: "Search in note"), id: DetailMemoChrome.search ) { - withAnimation(Motion.chip) { searchOpen.toggle() } + withAnimation(Motion.chip) { + if searchOpen { + closeSearch() + } else { + searchOpen = true + } + } } headerIconButton( systemImage: "ellipsis", @@ -421,7 +428,7 @@ struct MemoDetailView: View { .font(.system(size: 16)) .foregroundStyle(AppTheme.secondary) } - Text(memo.displayTitle) + Text(localizedTitle(for: memo)) .font(.system(size: 24, weight: .bold)) .foregroundStyle(AppTheme.title) .lineLimit(4) @@ -477,9 +484,40 @@ struct MemoDetailView: View { ) .font(.system(size: 14)) .textFieldStyle(.plain) + .onChange(of: searchQuery) { _, query in + searchMatchIndex = 0 + SharedTipTapRuntime.viewer.search(query, index: 0) + } + Text(searchMatchCount == 0 ? "0/0" : "\(searchMatchIndex + 1)/\(searchMatchCount)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + .monospacedDigit() Button { - searchOpen = false - searchQuery = "" + guard searchMatchCount > 0 else { return } + let next = (searchMatchIndex - 1 + searchMatchCount) % searchMatchCount + SharedTipTapRuntime.viewer.search(searchQuery, index: next) + } label: { + Image(systemName: "chevron.up") + .font(.system(size: 12, weight: .bold)) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .disabled(searchMatchCount == 0) + .accessibilityLabel(env.preferences.t("上一个匹配项", en: "Previous match")) + Button { + guard searchMatchCount > 0 else { return } + let next = (searchMatchIndex + 1) % searchMatchCount + SharedTipTapRuntime.viewer.search(searchQuery, index: next) + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .bold)) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .disabled(searchMatchCount == 0) + .accessibilityLabel(env.preferences.t("下一个匹配项", en: "Next match")) + Button { + closeSearch() } label: { Image(systemName: "xmark") .font(.system(size: 12, weight: .bold)) @@ -516,6 +554,9 @@ struct MemoDetailView: View { markdown: memo.contentMarkdown, baseURL: env.session.session.flatMap { URL(string: $0.baseUrl) }, token: env.session.session?.token, + locale: env.preferences.isEnglish ? "en-US" : "zh-CN", + theme: colorScheme == .dark ? "dark" : "light", + placeholder: env.preferences.t("开始输入…", en: "Start writing…"), onChange: nil, onResourcePress: { target in resourceTarget = target @@ -523,6 +564,11 @@ struct MemoDetailView: View { onImagePreview: { source, alt in imagePreview = (source, alt) }, + onPickImage: nil, + onSearchResult: { count, index in + searchMatchCount = count + searchMatchIndex = index + }, onBodyReady: { bodyReady = true } @@ -620,10 +666,23 @@ struct MemoDetailView: View { private func copyLocalDraft() async { guard let memo else { return } - let text = [memo.displayTitle, memo.contentMarkdown].filter { !$0.isEmpty }.joined(separator: "\n\n") + let text = [localizedTitle(for: memo), memo.contentMarkdown].filter { !$0.isEmpty }.joined(separator: "\n\n") UIPasteboard.general.string = text } + private func closeSearch() { + searchOpen = false + searchQuery = "" + searchMatchCount = 0 + searchMatchIndex = 0 + SharedTipTapRuntime.viewer.search("", index: 0) + } + + private func localizedTitle(for memo: MemoDetail) -> String { + let title = memo.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return title.isEmpty ? env.preferences.t("无标题笔记", en: "Untitled note") : title + } + private func togglePin(_ memo: MemoDetail) async { guard let scope = env.session.dataScope else { return } do { @@ -649,8 +708,12 @@ struct MemoDetailView: View { do { let share = try await env.session.client.createMemoShare(memoId: memo.id) let base = env.session.session?.baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" - shareURL = "\(base)/share/\(share.token)" - showShareAlert = true + guard let url = URL(string: "\(base)/share/\(share.token)") else { return } + let title = memo.title?.trimmingCharacters(in: .whitespacesAndNewlines) + let displayTitle = title?.isEmpty == false + ? title! + : env.preferences.t("无标题笔记", en: "Untitled note") + memoSharePayload = MemoSharePayload(message: "\(displayTitle)\n\(url.absoluteString)", url: url) } catch { self.error = error.localizedDescription } @@ -673,6 +736,12 @@ struct MemoDetailView: View { } } +private struct MemoSharePayload: Identifiable { + let id = UUID() + let message: String + let url: URL +} + // MARK: - String helper private extension String { diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift index 84b89a1e..131656d1 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift @@ -10,6 +10,7 @@ enum MemoEditMode: Equatable { struct MemoEditView: View { @Environment(AppEnvironment.self) private var env @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme let mode: MemoEditMode /// When set (edit-from-detail), close by popping to the list under the cover first — @@ -319,27 +320,6 @@ struct MemoEditView: View { .accessibilityLabel(env.preferences.t("笔记标签", en: "Tags")) .accessibilityIdentifier(CreateMemoChrome.tags) - Button { - // Resign WebView first responder so the picker sheet is not blocked. - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) - error = nil - showImagePicker = true - } label: { - Image(systemName: "photo") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(isUploading ? AppTheme.muted : AppTheme.slate) - .frame(width: 36, height: 32) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isUploading) - .accessibilityLabel(env.preferences.t("插入图片", en: "Insert image")) - .accessibilityIdentifier(CreateMemoChrome.imageTool) } .frame(minHeight: 40) .accessibilityIdentifier(CreateMemoChrome.metaRow) @@ -354,6 +334,9 @@ struct MemoEditView: View { markdown: contentMarkdown, baseURL: env.session.session.map { URL(string: $0.baseUrl) } ?? nil, token: env.session.session?.token, + locale: env.preferences.isEnglish ? "en-US" : "zh-CN", + theme: colorScheme == .dark ? "dark" : "light", + placeholder: env.preferences.t("开始输入…", en: "Start writing…"), onChange: { md, json in guard contentHydrated, !suppressPersistence else { return } // Accept the JSON and Markdown emitted by the same TipTap transaction. @@ -370,6 +353,18 @@ struct MemoEditView: View { resourceTarget = target }, onImagePreview: nil, + onPickImage: { + guard !isUploading else { return } + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + error = nil + showImagePicker = true + }, + onSearchResult: nil, onBodyReady: { // Do not focusEnd here — bodyReady also fires on typing re-binds. // Open-edit focus is owned by SharedTipTapRuntime (once per document). @@ -694,7 +689,7 @@ struct MemoEditView: View { NSLog("MemoEditView persist: mirror miss for \(memoId)") return } - memo.title = title.isEmpty ? "无标题笔记" : title + memo.title = title.isEmpty ? env.preferences.t("无标题笔记", en: "Untitled note") : title memo.contentMarkdown = contentMarkdown memo.contentText = contentMarkdown memo.tags = tags @@ -766,6 +761,7 @@ struct MemoEditView: View { expectedContentHash: expectedContentHash, notebookId: notebookId, title: title, + untitledTitle: env.preferences.t("无标题笔记", en: "Untitled note"), contentMarkdown: contentMarkdown, contentJSON: contentJSON, tags: tags, @@ -845,7 +841,7 @@ struct MemoEditView: View { viewModel.reconcileMarkdownWithJSON() let memo = try await env.session.client.createMemo( notebookId: notebookId.isEmpty ? (availableNotebooks.first?.id ?? "") : notebookId, - title: title.isEmpty ? "无标题笔记" : title, + title: title.isEmpty ? env.preferences.t("无标题笔记", en: "Untitled note") : title, contentMarkdown: contentMarkdown, tags: tags ) diff --git a/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift b/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift index 64966709..8cfacec3 100644 --- a/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift @@ -625,6 +625,6 @@ struct MemoCardContent: View { private var displayTitle: String { let t = memo.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return t.isEmpty ? "无标题笔记" : t + return t.isEmpty ? (isEnglish ? "Untitled note" : "无标题笔记") : t } } diff --git a/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift b/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift index c30ad23e..61d4eb55 100644 --- a/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift @@ -4,6 +4,7 @@ import SwiftUI struct SettingsView: View { @Environment(AppEnvironment.self) private var env @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme private enum RootTab: Hashable { case general @@ -18,6 +19,7 @@ struct SettingsView: View { @State private var tab: RootTab? @State private var showLocalePicker = false + @State private var copiedSystemInfo = false private var title: String { switch tab { @@ -100,16 +102,11 @@ struct SettingsView: View { Button { withAnimation(Motion.chip) { - // cycle: system → light → dark → system - switch env.preferences.theme { - case "light": env.preferences.theme = "dark" - case "dark": env.preferences.theme = "system" - default: env.preferences.theme = "light" - } + env.preferences.theme = resolvedDarkMode ? "light" : "dark" } } label: { HStack(spacing: 8) { - Image(systemName: env.preferences.theme == "dark" ? "sun.max" : "moon") + Image(systemName: resolvedDarkMode ? "sun.max" : "moon") .font(.system(size: 16, weight: .semibold)) Text(themeToggleLabel) .font(.system(size: 14, weight: .bold)) @@ -131,12 +128,16 @@ struct SettingsView: View { private var themeToggleLabel: String { // Android shows the *action* text: switch to light when dark, else switch to dark - if env.preferences.theme == "dark" || (env.preferences.theme == "system" && env.preferences.colorScheme == .dark) { + if resolvedDarkMode { return env.preferences.t("切换到浅色模式", en: "Light mode") } return env.preferences.t("切换到深色模式", en: "Dark mode") } + private var resolvedDarkMode: Bool { + env.preferences.theme == "dark" || (env.preferences.theme == "system" && colorScheme == .dark) + } + // MARK: - Root Me menu (Android activeTab === null) private var rootMenu: some View { @@ -190,9 +191,7 @@ struct SettingsView: View { trailing: .external, showBorder: true ) { - if let url = URL(string: "https://github.com/tianma-if/edgeever/issues") { - UIApplication.shared.open(url) - } + if let url = feedbackURL { UIApplication.shared.open(url) } } } } @@ -431,18 +430,31 @@ struct SettingsView: View { private var systemContent: some View { VStack(spacing: 16) { settingsGroup(title: env.preferences.t("系统信息", en: "System info"), icon: "info.circle") { - infoRow(env.preferences.t("版本", en: "Version"), - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—", - showBorder: false) - infoRow("Build", - Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—", - showBorder: true) + Button { + UIPasteboard.general.string = systemInfoText + copiedSystemInfo = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { copiedSystemInfo = false } + } label: { + HStack(spacing: 8) { + Image(systemName: copiedSystemInfo ? "checkmark.shield" : "doc.on.doc") + Text(copiedSystemInfo + ? env.preferences.t("已复制", en: "Copied") + : env.preferences.t("复制信息", en: "Copy info")) + .font(.system(size: 14, weight: .bold)) + Spacer() + } + .foregroundStyle(copiedSystemInfo ? AppTheme.accentStrong : AppTheme.title) + .padding(.horizontal, 16) + .frame(minHeight: 44) + } + .buttonStyle(.plain) + + ForEach(Array(systemInfoItems.enumerated()), id: \.offset) { index, item in + infoRow(item.label, item.value, showBorder: true) + } infoRow(env.preferences.t("实例", en: "Instance"), env.session.session?.baseUrl ?? "—", showBorder: true) - infoRow(env.preferences.t("运行环境", en: "Runtime"), - "Native SwiftUI", - showBorder: true) } Button { @@ -482,6 +494,64 @@ struct SettingsView: View { } } + private var systemInfoItems: [(label: String, value: String)] { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—" + let language = env.preferences.localeCode == "system" + ? "\(env.preferences.resolvedLocale.identifier) (\(env.preferences.t("跟随系统", en: "Follow system")))" + : env.preferences.resolvedLocale.identifier + return [ + (env.preferences.t("版本", en: "Version"), "v\(version)"), + (env.preferences.t("构建", en: "Build"), build), + (env.preferences.t("客户端", en: "Client"), env.preferences.t("移动应用", en: "Mobile app")), + (env.preferences.t("系统", en: "System"), "iOS"), + (env.preferences.t("系统版本", en: "System version"), UIDevice.current.systemVersion), + (env.preferences.t("语言", en: "Language"), language), + (env.preferences.t("时区", en: "Time zone"), TimeZone.current.identifier), + (env.preferences.t("安装形态", en: "Mode"), env.preferences.t("原生 SwiftUI 应用", en: "Native SwiftUI app")), + ] + } + + private var systemInfoText: String { + systemInfoItems.map { "\($0.label): \($0.value)" }.joined(separator: "\n") + } + + private var feedbackURL: URL? { + let english = env.preferences.isEnglish + let heading = english ? "Feedback" : "反馈内容" + let prompt = english + ? "Describe the problem, steps to reproduce it, or the feature you would like to see." + : "请描述遇到的问题、复现步骤,或你希望增加的功能。" + let privacy = english + ? "GitHub Issues are public. Do not include passwords, tokens, instance URLs, or private note content." + : "GitHub Issue 公开可见,请勿提交密码、Token、实例地址或私人笔记内容。" + let infoHeading = english ? "System information" : "系统信息" + let notice = english + ? "The following information was generated by EdgeEver to help diagnose the issue." + : "以下信息由 EdgeEver 自动生成,可帮助定位问题。" + let body = """ + ## \(heading) + + \(prompt) + + > \(privacy) + + ## \(infoHeading) + + \(notice) + + ```text + \(systemInfoText) + ``` + """ + var components = URLComponents(string: "https://github.com/tianma-if/edgeever/issues/new") + components?.queryItems = [ + URLQueryItem(name: "title", value: english ? "[Feedback] " : "[反馈] "), + URLQueryItem(name: "body", value: body), + ] + return components?.url + } + private func settingsGroup( title: String, icon: String, diff --git a/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift b/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift index b1f52f0b..94f2cf40 100644 --- a/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift +++ b/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift @@ -32,10 +32,6 @@ final class WorkspaceStore { return notebooks.first { $0.id == selectedNotebookId } } - var titleLabel: String { - activeNotebook?.name ?? "全部笔记" - } - func reload(env: AppEnvironment, resetOffset: Bool = true) { guard let scope = env.session.dataScope else { notebooks = [] @@ -117,6 +113,25 @@ final class WorkspaceStore { selectedMemoIds = MobileUI.toggleMemoSelection(current: selectedMemoIds, memoId: memoId) } + var allVisibleMemosSelected: Bool { + !memos.isEmpty && memos.allSatisfy { selectedMemoIds.contains($0.id) } + } + + var nextSelectionPinValue: Bool { + let selected = memos.filter { selectedMemoIds.contains($0.id) } + return !selected.isEmpty && !selected.allSatisfy(\.isPinned) + } + + func toggleVisibleSelection() { + let visibleIds = Set(memos.map(\.id)) + if allVisibleMemosSelected { + selectedMemoIds.subtract(visibleIds) + } else { + selectionMode = true + selectedMemoIds.formUnion(visibleIds) + } + } + /// Request a rebound animation on a list card after create/edit returns to the list. func requestMemoBounce(memoId: String?) { guard let memoId, !memoId.isEmpty else { return } @@ -174,6 +189,30 @@ final class WorkspaceStore { } } + func pinSelection(env: AppEnvironment, isPinned: Bool) async { + guard let scope = env.session.dataScope, !selectedMemoIds.isEmpty else { return } + do { + for memoId in selectedMemoIds { + let updated = try await env.session.client.updateMemo( + id: memoId, + expectedRevision: nil, + expectedContentHash: nil, + editSessionId: nil, + notebookId: nil, + title: nil, + isPinned: isPinned, + contentMarkdown: nil, + tags: nil + ) + try env.mirror.upsertMemo(scope: scope, memo: updated) + } + clearSelection() + reload(env: env) + } catch { + listError = error.localizedDescription + } + } + func moveSelection(env: AppEnvironment, notebookId: String) async { guard let scope = env.session.dataScope else { return } for memoId in selectedMemoIds { diff --git a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift index cd102e7c..a163afac 100644 --- a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift @@ -18,6 +18,7 @@ struct WorkspaceView: View { @State private var showTemplatePicker = false @State private var createLongPressConsumed = false @State private var showMoveSheet = false + @State private var showSelectionMore = false @State private var conflictItem: OutboxItem? @State private var createTapCount = 0 @State private var syncPulse = 0 @@ -25,6 +26,9 @@ struct WorkspaceView: View { @State private var editingMemo: EditingMemoRoute? /// Create finished id, applied as list bounce after cover dismiss + reload. @State private var pendingCreateBounceId: String? + @State private var incomingClipURL: URL? + @State private var isImportingShare = false + @State private var shareImportAlert: ShareImportAlert? /// Android SafeAreaView edges=[top,left,right] — bottom chrome owns home indicator. private var showsBottomChrome: Bool { @@ -150,6 +154,11 @@ struct WorkspaceView: View { } } } + .sheet(isPresented: $showSelectionMore) { + SelectionMoreSheet(store: store) + .presentationDetents([.height(290), .medium]) + .presentationDragIndicator(.hidden) + } .sheet(item: $conflictItem) { item in ConflictResolutionView(item: item) { conflictItem = nil @@ -187,6 +196,49 @@ struct WorkspaceView: View { store.reload(env: env) detectConflicts() } + .overlay { + if isImportingShare { + ZStack { + Color.black.opacity(0.34).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + .tint(AppTheme.accent) + Text(env.preferences.t("正在剪藏文章", en: "Clipping article")) + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(AppTheme.title) + Text(env.preferences.t("正在提取标题、正文和图片链接…", en: "Extracting the title, body, and image links…")) + .font(.system(size: 13)) + .foregroundStyle(AppTheme.secondary) + .multilineTextAlignment(.center) + } + .padding(24) + .background(AppTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + + if let incomingClipURL { + WebClipCaptureView( + url: incomingClipURL, + onCaptured: { page in finishRenderedClip(page, sourceURL: incomingClipURL) }, + onFailed: { message in failRenderedClip(message, sourceURL: incomingClipURL) } + ) + .frame(width: 1, height: 1) + .opacity(0.01) + .allowsHitTesting(false) + } + } + .accessibilityIdentifier("shareImportOverlay") + } + } + .alert(item: $shareImportAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text(env.preferences.t("好的", en: "OK"))) { + if let draft = alert.draft { finishClip(draft) } + } + ) + } } .preferredColorScheme(env.preferences.colorScheme) } @@ -222,7 +274,7 @@ struct WorkspaceView: View { store.showNotebookPicker = true } label: { HStack(spacing: 4) { - Text(store.titleLabel) + Text(store.activeNotebook?.name ?? env.preferences.t("全部笔记", en: "All notes")) .font(AppTheme.notebookTitleFont) .foregroundStyle(AppTheme.title) .lineLimit(1) @@ -546,7 +598,7 @@ struct WorkspaceView: View { private var selectionBar: some View { let bottomInset = MobileUIMetrics.bottomSafeInset return VStack(spacing: 0) { - HStack(spacing: 24) { + HStack(spacing: 0) { Button { showMoveSheet = true } label: { @@ -555,6 +607,8 @@ struct WorkspaceView: View { Text(env.preferences.t("移动", en: "Move")).font(.caption2.weight(.bold)) } } + .frame(maxWidth: .infinity) + .disabled(store.selectedMemoIds.isEmpty) Button(role: .destructive) { Task { await store.softDeleteSelection(env: env) } } label: { @@ -563,12 +617,19 @@ struct WorkspaceView: View { Text(env.preferences.t("删除", en: "Delete")).font(.caption2.weight(.bold)) } } - Spacer() - Text(env.preferences.t("已选 \(store.selectedMemoIds.count)", en: "\(store.selectedMemoIds.count) selected")) - .font(.caption.weight(.bold)) - .foregroundStyle(AppTheme.secondary) + .frame(maxWidth: .infinity) + .disabled(store.selectedMemoIds.isEmpty) + Button { + showSelectionMore = true + } label: { + VStack(spacing: 4) { + Image(systemName: "ellipsis") + Text(env.preferences.t("更多", en: "More")).font(.caption2.weight(.bold)) + } + } + .frame(maxWidth: .infinity) } - .padding(.horizontal, 32) + .padding(.horizontal, 20) .padding(.top, 4) .frame(height: MobileUIMetrics.bottomNavigationHeight, alignment: .top) .frame(maxWidth: .infinity) @@ -621,26 +682,57 @@ struct WorkspaceView: View { private func consumeShare() { let payloads = env.shareHandoff.consumePending() - guard let first = payloads.first, let scope = env.session.dataScope else { return } - var bodyParts: [String] = [] - if let title = first.title, !title.isEmpty { bodyParts.append(title) } - if let url = first.url, !url.isEmpty { bodyParts.append(url) } - if let text = first.text, !text.isEmpty { bodyParts.append(text) } - let body = bodyParts.joined(separator: "\n\n") - try? env.drafts.write( - scope: scope, - draft: MemoDraft( - draftKey: DraftRepository.newKey, - title: first.title ?? "", - contentMarkdown: body, - contentJson: nil, - notebookId: store.selectedNotebookId ?? store.notebooks.first?.id ?? "", - tagsText: "", - expectedRevision: nil, - updatedAt: EdgeEverDate.nowString() + guard !payloads.isEmpty else { return } + guard let sourceURL = WebClipper.sharedWebURL(from: payloads) else { + shareImportAlert = ShareImportAlert( + title: env.preferences.t("无法剪藏", en: "Unable to clip"), + message: env.preferences.t("分享内容里没有可识别的网页链接。", en: "The shared content does not contain a recognizable web link.") ) - ) - openCreateNote() + return + } + guard !store.notebooks.isEmpty else { + shareImportAlert = ShareImportAlert( + title: env.preferences.t("无法保存剪藏", en: "Unable to save clip"), + message: env.preferences.t("请先在 EdgeEver 中创建一个笔记本。", en: "Create a notebook in EdgeEver first.") + ) + return + } + + isImportingShare = true + if WebClipper.isWeChatArticle(sourceURL) { + incomingClipURL = sourceURL + } else { + Task { + let draft = await WebClipper.build(sourceURL) + finishClip(draft) + } + } + } + + private func finishRenderedClip(_ page: RenderedWebPage, sourceURL: URL) { + finishClip(WebClipper.buildRendered(sourceURL, page: page)) + } + + private func failRenderedClip(_ message: String, sourceURL: URL) { + incomingClipURL = nil + Task { + let draft = await WebClipper.build(sourceURL) + isImportingShare = false + shareImportAlert = ShareImportAlert( + title: env.preferences.t("正文剪藏失败", en: "Article extraction failed"), + message: message + env.preferences.t( + " 已保留文章链接,你可以稍后重新分享重试。", + en: " The article link was preserved; you can share it again later to retry." + ), + draft: draft + ) + } + } + + private func finishClip(_ draft: WebClipDraft) { + incomingClipURL = nil + isImportingShare = false + openCreateNote(seed: draft.createSeed) } private func detectConflicts() { @@ -650,8 +742,114 @@ struct WorkspaceView: View { } } +private struct ShareImportAlert: Identifiable { + let id = UUID() + let title: String + let message: String + var draft: WebClipDraft? = nil +} + // NotebookPickerSheet / ListActionsSheet live in their own files for Android parity. +struct SelectionMoreSheet: View { + @Environment(AppEnvironment.self) private var env + @Environment(\.dismiss) private var dismiss + @Bindable var store: WorkspaceStore + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(AppTheme.sheetHandle) + .frame(width: 42, height: 4) + .padding(.top, 10) + .padding(.bottom, 12) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 3) { + Text(env.preferences.t("批量操作", en: "Batch actions")) + .font(.system(size: 16, weight: .heavy)) + .foregroundStyle(AppTheme.title) + Text(store.selectedMemoIds.isEmpty + ? env.preferences.t("选择笔记", en: "Select notes") + : env.preferences.t("已选择 \(store.selectedMemoIds.count) 条", en: "\(store.selectedMemoIds.count) selected")) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + } + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppTheme.title) + .frame(width: 34, height: 34) + } + .buttonStyle(.plain) + .accessibilityLabel(env.preferences.t("关闭", en: "Close")) + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + + selectionAction( + icon: "checkmark.square", + label: store.allVisibleMemosSelected + ? env.preferences.t("全不选当前列表", en: "Deselect current list") + : env.preferences.t("全选当前列表", en: "Select current list"), + disabled: store.memos.isEmpty + ) { + store.toggleVisibleSelection() + dismiss() + } + + selectionAction( + icon: "sparkles", + label: store.nextSelectionPinValue + ? env.preferences.t("置顶", en: "Pin") + : env.preferences.t("取消置顶", en: "Unpin"), + disabled: store.selectedMemoIds.isEmpty + ) { + let target = store.nextSelectionPinValue + dismiss() + Task { await store.pinSelection(env: env, isPinned: target) } + } + + selectionAction( + icon: "xmark", + label: env.preferences.t("取消选择", en: "Clear selection"), + disabled: false + ) { + store.clearSelection() + dismiss() + } + + Spacer(minLength: 0) + } + .background(AppTheme.card) + } + + private func selectionAction( + icon: String, + label: String, + disabled: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .frame(width: 24) + Text(label) + .font(.system(size: 15, weight: .semibold)) + Spacer() + } + .foregroundStyle(disabled ? AppTheme.muted : AppTheme.title) + .padding(.horizontal, 18) + .frame(minHeight: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + } +} + struct MoveNotebookSheet: View { @Environment(AppEnvironment.self) private var env let notebooks: [Notebook] diff --git a/apps/ios/EdgeEver/Resources/EditorBundle/index.html b/apps/ios/EdgeEver/Resources/EditorBundle/index.html index f71ebe09..dce32416 100644 --- a/apps/ios/EdgeEver/Resources/EditorBundle/index.html +++ b/apps/ios/EdgeEver/Resources/EditorBundle/index.html @@ -7,38 +7,38 @@ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> EdgeEver Editor - +`},"getStyles"),Ax,Bl=(Ax=class{constructor(){this.d=""}moveTo(e,r){return this.d+=`M ${e} ${r} `,this}lineTo(e,r){return this.d+=`L ${e} ${r} `,this}horizontalTo(e){return this.d+=`H ${e} `,this}verticalTo(e){return this.d+=`V ${e} `,this}arcTo(e,r,n,i,a,s,o){return this.d+=`A ${e} ${r} ${n} ${i?1:0} ${a?1:0} ${s} ${o} `,this}build(){return this.d.trim()}},T(Ax,"PathBuilder"),Ax),_x,iHt=(_x=class{constructor(e,r=jj()){this.textCache=new Map,this.svg=e,this.config=r}measureText(e){if(this.textCache.has(e))return this.textCache.get(e);const r=this.svg.append("text").attr("font-family",this.config.fontFamily).attr("font-size",this.config.fontSize).text(e),n=r.node().getBBox(),i={width:n.width,height:n.height};return r.remove(),this.textCache.set(e,i),i}renderTerminal(e,r){const n=this.measureText(r),i=n.width+this.config.padding*2,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-terminal");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a).attr("rx",10).attr("ry",10),s.append("text").attr("x",i/2).attr("y",a/2).text(r),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/2}}}renderNonTerminal(e,r){const n=this.measureText(r),i=n.width+this.config.padding*2,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-nonterminal");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a),s.append("text").attr("x",i/2).attr("y",a/2).text(r),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/2}}}renderSequence(e,r){const n=r.map(u=>this.renderExpression(e,u));let i=0,a=0,s=0;for(const u of n)i+=u.dimensions.width,a=Math.max(a,u.dimensions.up),s=Math.max(s,u.dimensions.down);i+=(n.length-1)*this.config.horizontalSeparation;const o=e.append("g").attr("class","railroad-sequence");let l=0;for(let u=0;uthis.renderExpression(e,f));let i=0,a=0;for(const f of n)i=Math.max(i,f.dimensions.width),a+=f.dimensions.height;a+=(n.length-1)*this.config.verticalSeparation;const s=this.config.arcRadius,o=s*4,l=i+o,u=e.append("g").attr("class","railroad-choice");let h=0;const d=a/2;for(const f of n){const p=h,g=p+f.dimensions.up,m=s*2+(i-f.dimensions.width)/2;u.node().appendChild(f.element).setAttribute("transform",`translate(${m}, ${p})`);const v=new Bl,x=g>d;g===d?v.moveTo(0,d).lineTo(m,g):v.moveTo(0,d).arcTo(s,s,0,!1,x,s,d+(x?s:-s)).lineTo(s,g-(x?s:-s)).arcTo(s,s,0,!1,!x,s*2,g).lineTo(m,g),u.append("path").attr("class","railroad-line").attr("d",v.build());const b=new Bl,w=m+f.dimensions.width,k=l-s*2;g===d?b.moveTo(w,g).lineTo(l,d):b.moveTo(w,g).lineTo(k,g).arcTo(s,s,0,!1,!x,l-s,g+(x?-s:s)).lineTo(l-s,d+(x?s:-s)).arcTo(s,s,0,!1,x,l,d),u.append("path").attr("class","railroad-line").attr("d",b.build()),h+=f.dimensions.height+this.config.verticalSeparation}return{element:u.node(),dimensions:{width:l,height:a,up:d,down:a-d}}}renderOptional(e,r){const n=this.renderExpression(e,r),i=this.config.arcRadius,a=i*2,s=n.dimensions.width+i*4,o=n.dimensions.height+a,l=e.append("g").attr("class","railroad-optional"),u=i*2,h=a;l.node().appendChild(n.element).setAttribute("transform",`translate(${u}, ${h})`);const f=h+n.dimensions.up,p=new Bl().moveTo(0,f).lineTo(i*2,f);l.append("path").attr("class","railroad-line").attr("d",p.build());const g=new Bl().moveTo(u+n.dimensions.width,f).lineTo(s,f);l.append("path").attr("class","railroad-line").attr("d",g.build());const m=new Bl().moveTo(0,f).arcTo(i,i,0,!1,!1,i,f-i).lineTo(i,i).arcTo(i,i,0,!1,!0,i*2,0).lineTo(s-i*2,0).arcTo(i,i,0,!1,!0,s-i,i).lineTo(s-i,f-i).arcTo(i,i,0,!1,!1,s,f);return l.append("path").attr("class","railroad-line").attr("d",m.build()),{element:l.node(),dimensions:{width:s,height:o,up:f,down:o-f}}}renderRepetition(e,r,n){const i=this.renderExpression(e,r),a=this.config.arcRadius,s=a*2,o=i.dimensions.width+a*4,l=n===0,u=i.dimensions.height+s+(l?s:0),h=e.append("g").attr("class","railroad-repetition"),d=a*2,f=l?s:0;h.node().appendChild(i.element).setAttribute("transform",`translate(${d}, ${f})`);const g=f+i.dimensions.up;h.append("path").attr("class","railroad-line").attr("d",new Bl().moveTo(0,g).lineTo(a*2,g).build()),h.append("path").attr("class","railroad-line").attr("d",new Bl().moveTo(d+i.dimensions.width,g).lineTo(o,g).build());const m=f+i.dimensions.height+a,y=new Bl().moveTo(d+i.dimensions.width,g).arcTo(a,a,0,!1,!0,d+i.dimensions.width+a,g+a).lineTo(d+i.dimensions.width+a,m).arcTo(a,a,0,!1,!0,d+i.dimensions.width,m+a).lineTo(a*2,m+a).arcTo(a,a,0,!1,!0,a,m).lineTo(a,g+a).arcTo(a,a,0,!1,!0,a*2,g);if(h.append("path").attr("class","railroad-line").attr("d",y.build()),l){const v=new Bl().moveTo(0,g).arcTo(a,a,0,!1,!1,a,g-a).lineTo(a,a).arcTo(a,a,0,!1,!0,a*2,0).lineTo(o-a*2,0).arcTo(a,a,0,!1,!0,o-a,a).lineTo(o-a,g-a).arcTo(a,a,0,!1,!1,o,g);h.append("path").attr("class","railroad-line").attr("d",v.build())}return{element:h.node(),dimensions:{width:o,height:u,up:g,down:u-g}}}renderSpecial(e,r){const n=this.measureText("? "+r+" ?"),i=n.width+this.config.padding*2,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-special");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a),s.append("text").attr("x",i/2).attr("y",a/2).text("? "+r+" ?"),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/2}}}renderExpression(e,r){switch(r.type){case"terminal":return this.renderTerminal(e,r.value);case"nonterminal":return this.renderNonTerminal(e,r.name);case"sequence":return this.renderSequence(e,r.elements);case"choice":return this.renderChoice(e,r.alternatives);case"optional":return this.renderOptional(e,r.element);case"repetition":return this.renderRepetition(e,r.element,r.min);case"special":return this.renderSpecial(e,r.text);default:throw new Error(`Unknown node type: ${r.type}`)}}renderRule(e,r){const n=this.svg.append("g").attr("class","railroad-rule").attr("transform",`translate(0, ${r})`),i=e.name+" =",a=this.measureText(i).width+20,s=a+20,o=n.append("g"),l=this.renderExpression(o,e.definition),u=Math.max(20,l.dimensions.up),h=u-l.dimensions.up;return o.attr("transform",`translate(${s}, ${h})`),n.append("g").attr("class","railroad-rule-name-group").append("text").attr("class","railroad-rule-name").attr("x",0).attr("y",u).text(i),n.append("g").attr("class","railroad-start").append("circle").attr("cx",a).attr("cy",u).attr("r",this.config.markerRadius),n.append("g").attr("class","railroad-end").append("circle").attr("cx",s+l.dimensions.width+10).attr("cy",u).attr("r",this.config.markerRadius),n.append("path").attr("class","railroad-line").attr("d",new Bl().moveTo(a+this.config.markerRadius,u).lineTo(s,u).build()),n.append("path").attr("class","railroad-line").attr("d",new Bl().moveTo(s+l.dimensions.width,u).lineTo(s+l.dimensions.width+10-this.config.markerRadius,u).build()),{height:Math.max(40,h+l.dimensions.height+this.config.padding*2),width:s+l.dimensions.width+10+this.config.markerRadius}}renderDiagram(e){let r=this.config.padding,n=0;for(const i of e){const a=this.renderRule(i,r);r+=a.height+this.config.verticalSeparation,n=Math.max(n,a.width)}return{width:n+this.config.padding*2,height:r+this.config.padding}}},T(_x,"RailroadRenderer"),_x),zle=T((t,e,r)=>{Ji(t,e.height,e.width,r),t.attr("viewBox",`0 0 ${e.width} ${e.height}`)},"configureRailroadSvgSize"),aHt=T((t,e,r)=>{he.debug(`[Railroad] Rendering diagram +`+t);try{const n=js(e);n.attr("class","railroad-diagram");const i=lr().railroad,a=(i==null?void 0:i.useMaxWidth)??!0,s=wi.getRules();if(he.debug(`[Railroad] Rendering ${s.length} rules`),s.length===0){he.warn("[Railroad] No rules to render"),zle(n,{height:100,width:200},a);return}const l=new iHt(n,jj()).renderDiagram(s);zle(n,l,a),he.debug("[Railroad] Render complete")}catch(n){throw he.error("[Railroad] Render error:",n),n}},"draw"),VL={draw:aHt},sHt=UX().Railroad.parser.LangiumParser,ty=T(t=>{switch(t.$type){case"RailroadTerminalExpr":return{type:"terminal",value:t.value};case"RailroadNonTerminalExpr":return{type:"nonterminal",name:t.name};case"RailroadSpecialExpr":return{type:"special",text:t.text};case"RailroadSequenceExpr":{const e=t.elements.map(ty);return e.length===1?e[0]:{type:"sequence",elements:e}}case"RailroadChoiceExpr":{const e=t.alternatives.map(ty);return e.length===1?e[0]:{type:"choice",alternatives:e}}case"RailroadOptionalExpr":return{type:"optional",element:ty(t.element)};case"RailroadOneOrMoreExpr":return{type:"repetition",element:ty(t.element),min:1,max:1/0};case"RailroadZeroOrMoreExpr":return{type:"repetition",element:ty(t.element),min:0,max:1/0};default:throw new Error(`Unsupported railroad expression: ${t.$type}`)}},"transformExpression"),oHt=T(t=>({name:t.name,definition:ty(t.definition)}),"transformRule"),lHt=T(t=>{No(t,wi),t.title&&wi.setTitle(t.title),t.rules.map(e=>wi.addRule(oHt(e)))},"populateDb"),cHt={parse:T(t=>{wi.clear(),he.debug("[Railroad Parser] Starting Langium parse");const e=sHt.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new mC(e);const r=e.value;he.debug("[Railroad Parser] Parsed rules:",r.rules.length),lHt(r),he.debug("[Railroad Parser] Parse complete")},"parse"),parser:{yy:wi}},uHt={parser:cHt,db:wi,renderer:VL,styles:zL};const hHt=Object.freeze(Object.defineProperty({__proto__:null,diagram:uHt},Symbol.toStringTag,{value:"Module"}));var dHt=YX().RailroadEbnf.parser.LangiumParser,GE=T(t=>{const e=t.alternatives.map(fHt);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformChoice"),fHt=T(t=>{const e=t.elements.map(gHt);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),AIe=T(t=>{switch(t.$type){case"EbnfTerminal":return{type:"terminal",value:t.value};case"EbnfNonTerminal":return{type:"nonterminal",name:t.name};case"EbnfSpecial":return{type:"special",text:t.text};case"EbnfGroup":return GE(t.element);case"EbnfOptional":return{type:"optional",element:GE(t.element)};case"EbnfRepetition":return{type:"repetition",element:GE(t.element),min:0,max:1/0};default:throw new Error(`Unsupported EBNF primary node: ${t.$type}`)}},"transformPrimary"),pHt=T((t,e)=>{switch(e.$type){case"EbnfOptionalPostfix":return{type:"optional",element:t};case"EbnfZeroOrMorePostfix":return{type:"repetition",element:t,min:0,max:1/0};case"EbnfOneOrMorePostfix":return{type:"repetition",element:t,min:1,max:1/0};case"EbnfExceptionPostfix":return{type:"sequence",elements:[t,{type:"terminal",value:"-"},AIe(e.except)]};default:throw new Error(`Unsupported EBNF postfix node: ${e.$type}`)}},"transformPostfix"),gHt=T(t=>t.postfixes.reduce((e,r)=>pHt(e,r),AIe(t.base)),"transformTerm"),mHt=T(t=>({name:t.name,definition:GE(t.definition)}),"transformRule"),yHt=T(t=>{No(t,wi),t.title&&wi.setTitle(t.title),t.rules.map(e=>wi.addRule(mHt(e)))},"populateDb"),vHt={parse:T(t=>{wi.clear(),he.debug("[EBNF Parser] Starting Langium parse");const e=dHt.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new mC(e);const r=e.value;he.debug("[EBNF Parser] Parsed rules:",r.rules.length),yHt(r),he.debug("[EBNF Parser] Parse complete")},"parse"),parser:{yy:wi}},bHt={parser:vHt,db:wi,renderer:VL,styles:zL};const xHt=Object.freeze(Object.defineProperty({__proto__:null,diagram:bHt},Symbol.toStringTag,{value:"Module"}));var THt=XX().RailroadAbnf.parser.LangiumParser,zV=T(t=>{const e=t.alternatives.map(wHt);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformAlternation"),wHt=T(t=>{const e=t.elements.map(CHt);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformConcatenation"),kHt=T(t=>{if(t.includes("*")){const[r,n]=t.split("*"),i=r?parseInt(r,10):0,a=n?parseInt(n,10):1/0;return{min:i,max:a}}const e=parseInt(t,10);return{min:e,max:e}},"parseRepeat"),CHt=T(t=>{const e=SHt(t.primary);if(!t.repeat)return e;const{min:r,max:n}=kHt(t.repeat);return r===0&&n===1?{type:"optional",element:e}:{type:"repetition",element:e,min:r,max:n}},"transformElement"),SHt=T(t=>{switch(t.$type){case"AbnfStringLiteral":return{type:"terminal",value:t.value};case"AbnfNumVal":return{type:"terminal",value:t.value};case"AbnfRuleName":return{type:"nonterminal",name:t.name};case"AbnfGroup":return zV(t.element);case"AbnfOptionalGroup":return{type:"optional",element:zV(t.element)};default:throw new Error(`Unsupported ABNF primary node: ${t.$type}`)}},"transformPrimary"),EHt=T(t=>({name:t.name,definition:zV(t.definition)}),"transformRule"),AHt=T(t=>{No(t,wi),t.title&&wi.setTitle(t.title),t.rules.map(e=>wi.addRule(EHt(e)))},"populateDb"),_Ht={parse:T(t=>{wi.clear(),he.debug("[ABNF Parser] Starting Langium parse");const e=THt.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new mC(e);const r=e.value;he.debug("[ABNF Parser] Parsed rules:",r.rules.length),AHt(r),he.debug("[ABNF Parser] Parse complete")},"parse"),parser:{yy:wi}},RHt={parser:_Ht,db:wi,renderer:VL,styles:zL};const LHt=Object.freeze(Object.defineProperty({__proto__:null,diagram:RHt},Symbol.toStringTag,{value:"Module"}));var MHt=jX().RailroadPeg.parser.LangiumParser,_Ie=T(t=>{const e=t.alternatives.map(IHt);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformOrderedChoice"),IHt=T(t=>{const e=t.elements.map(NHt);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),NHt=T(t=>{const e=DHt(t.suffix);return t.operator?{type:"special",text:t.operator==="&"?`&${Vle(e)}`:`!${Vle(e)}`}:e},"transformPrefix"),Vle=T(t=>{switch(t.type){case"terminal":return`"${t.value}"`;case"nonterminal":return t.name;case"special":return t.text;default:return"(...)"}},"nodeToLabel"),DHt=T(t=>{const e=OHt(t.primary);if(!t.operator)return e;switch(t.operator){case"?":return{type:"optional",element:e};case"*":return{type:"repetition",element:e,min:0,max:1/0};case"+":return{type:"repetition",element:e,min:1,max:1/0};default:throw new Error(`Unsupported PEG suffix operator: ${t.operator}`)}},"transformSuffix"),OHt=T(t=>{switch(t.$type){case"PegLiteral":return{type:"terminal",value:t.value};case"PegIdentifier":return{type:"nonterminal",name:t.name};case"PegGroup":return _Ie(t.element);case"PegAny":return{type:"special",text:t.dot};default:throw new Error(`Unsupported PEG primary node: ${t.$type}`)}},"transformPrimary"),$Ht=T(t=>({name:t.name,definition:_Ie(t.definition)}),"transformRule"),PHt=T(t=>{No(t,wi),t.title&&wi.setTitle(t.title),t.rules.map(e=>wi.addRule($Ht(e)))},"populateDb"),BHt={parse:T(t=>{wi.clear(),he.debug("[PEG Parser] Starting Langium parse");const e=MHt.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new mC(e);const r=e.value;he.debug("[PEG Parser] Parsed rules:",r.rules.length),PHt(r),he.debug("[PEG Parser] Parse complete")},"parse"),parser:{yy:wi}},FHt={parser:BHt,db:wi,renderer:VL,styles:zL};const zHt=Object.freeze(Object.defineProperty({__proto__:null,diagram:FHt},Symbol.toStringTag,{value:"Module"}));var VHt=1;function Kj(){if(!(typeof globalThis>"u"))return globalThis}T(Kj,"getCaptureGlobal");function RIe(){var t;return!!((t=Kj())!=null&&t.mermaidCaptureSizes)}T(RIe,"shouldCaptureSizes");function LIe(){return typeof location>"u"?"browser-dev":`${location.pathname}${location.search}`}T(LIe,"capturedFromLocation");function MIe(t,e){const r=Kj();if(!r)return;const n=e.node(),i=(n&&"ownerSVGElement"in n?n.ownerSVGElement:null)??n,a=(i==null?void 0:i.id)??"(unknown)";r.mermaidCapturedSizes??(r.mermaidCapturedSizes=[]);const s={svgId:a,sizes:t};r.mermaidCapturedSizes.push(s),r.mermaidLastCapturedSizes=s}T(MIe,"emitCapturedSizes");function IIe(t,e){const r=[];for(const n of e.nodes)n.isGroup||r.push({id:n.id,width:n.width??0,height:n.height??0});r.length!==0&&MIe({metadata:{captureVersion:VHt,capturedAt:new Date().toISOString(),capturedFrom:LIe()},nodes:r},t)}T(IIe,"captureNodeSizes");const HHt=Object.freeze(Object.defineProperty({__proto__:null,captureNodeSizes:IIe,shouldCaptureSizes:RIe},Symbol.toStringTag,{value:"Module"})),GHt=Object.freeze(Object.defineProperty({__proto__:null,InfoModule:U9e,createInfoServices:Y9e},Symbol.toStringTag,{value:"Module"})),qHt=Object.freeze(Object.defineProperty({__proto__:null,PacketModule:X9e,createPacketServices:j9e},Symbol.toStringTag,{value:"Module"})),WHt=Object.freeze(Object.defineProperty({__proto__:null,PieModule:K9e,createPieServices:Z9e},Symbol.toStringTag,{value:"Module"})),UHt=Object.freeze(Object.defineProperty({__proto__:null,TreeViewModule:Q9e,createTreeViewServices:J9e},Symbol.toStringTag,{value:"Module"})),YHt=Object.freeze(Object.defineProperty({__proto__:null,ArchitectureModule:e_e,createArchitectureServices:t_e},Symbol.toStringTag,{value:"Module"})),XHt=Object.freeze(Object.defineProperty({__proto__:null,GitGraphModule:q9e,createGitGraphServices:W9e},Symbol.toStringTag,{value:"Module"})),jHt=Object.freeze(Object.defineProperty({__proto__:null,EventModelingModule:n_e,createEventModelingServices:i_e},Symbol.toStringTag,{value:"Module"})),KHt=Object.freeze(Object.defineProperty({__proto__:null,RadarModule:M9e,createRadarServices:I9e},Symbol.toStringTag,{value:"Module"})),ZHt=Object.freeze(Object.defineProperty({__proto__:null,RailroadModule:N9e,createRailroadServices:UX},Symbol.toStringTag,{value:"Module"})),QHt=Object.freeze(Object.defineProperty({__proto__:null,RailroadEbnfModule:D9e,createRailroadEbnfServices:YX},Symbol.toStringTag,{value:"Module"})),JHt=Object.freeze(Object.defineProperty({__proto__:null,RailroadAbnfModule:O9e,createRailroadAbnfServices:XX},Symbol.toStringTag,{value:"Module"})),eGt=Object.freeze(Object.defineProperty({__proto__:null,RailroadPegModule:$9e,createRailroadPegServices:jX},Symbol.toStringTag,{value:"Module"})),tGt=Object.freeze(Object.defineProperty({__proto__:null,TreemapModule:B9e,createTreemapServices:F9e},Symbol.toStringTag,{value:"Module"})),rGt=Object.freeze(Object.defineProperty({__proto__:null,WardleyModule:z9e,createWardleyServices:V9e},Symbol.toStringTag,{value:"Module"})),nGt=Object.freeze(Object.defineProperty({__proto__:null,CynefinModule:H9e,createCynefinServices:G9e},Symbol.toStringTag,{value:"Module"})); diff --git a/apps/ios/EditorBundle/index.html b/apps/ios/EditorBundle/index.html index f71ebe09..dce32416 100644 --- a/apps/ios/EditorBundle/index.html +++ b/apps/ios/EditorBundle/index.html @@ -7,38 +7,38 @@ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> EdgeEver Editor - +`},"getStyles"),Ax,Bl=(Ax=class{constructor(){this.d=""}moveTo(e,r){return this.d+=`M ${e} ${r} `,this}lineTo(e,r){return this.d+=`L ${e} ${r} `,this}horizontalTo(e){return this.d+=`H ${e} `,this}verticalTo(e){return this.d+=`V ${e} `,this}arcTo(e,r,n,i,a,s,o){return this.d+=`A ${e} ${r} ${n} ${i?1:0} ${a?1:0} ${s} ${o} `,this}build(){return this.d.trim()}},T(Ax,"PathBuilder"),Ax),_x,iHt=(_x=class{constructor(e,r=jj()){this.textCache=new Map,this.svg=e,this.config=r}measureText(e){if(this.textCache.has(e))return this.textCache.get(e);const r=this.svg.append("text").attr("font-family",this.config.fontFamily).attr("font-size",this.config.fontSize).text(e),n=r.node().getBBox(),i={width:n.width,height:n.height};return r.remove(),this.textCache.set(e,i),i}renderTerminal(e,r){const n=this.measureText(r),i=n.width+this.config.padding*2,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-terminal");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a).attr("rx",10).attr("ry",10),s.append("text").attr("x",i/2).attr("y",a/2).text(r),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/2}}}renderNonTerminal(e,r){const n=this.measureText(r),i=n.width+this.config.padding*2,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-nonterminal");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a),s.append("text").attr("x",i/2).attr("y",a/2).text(r),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/2}}}renderSequence(e,r){const n=r.map(u=>this.renderExpression(e,u));let i=0,a=0,s=0;for(const u of n)i+=u.dimensions.width,a=Math.max(a,u.dimensions.up),s=Math.max(s,u.dimensions.down);i+=(n.length-1)*this.config.horizontalSeparation;const o=e.append("g").attr("class","railroad-sequence");let l=0;for(let u=0;uthis.renderExpression(e,f));let i=0,a=0;for(const f of n)i=Math.max(i,f.dimensions.width),a+=f.dimensions.height;a+=(n.length-1)*this.config.verticalSeparation;const s=this.config.arcRadius,o=s*4,l=i+o,u=e.append("g").attr("class","railroad-choice");let h=0;const d=a/2;for(const f of n){const p=h,g=p+f.dimensions.up,m=s*2+(i-f.dimensions.width)/2;u.node().appendChild(f.element).setAttribute("transform",`translate(${m}, ${p})`);const v=new Bl,x=g>d;g===d?v.moveTo(0,d).lineTo(m,g):v.moveTo(0,d).arcTo(s,s,0,!1,x,s,d+(x?s:-s)).lineTo(s,g-(x?s:-s)).arcTo(s,s,0,!1,!x,s*2,g).lineTo(m,g),u.append("path").attr("class","railroad-line").attr("d",v.build());const b=new Bl,w=m+f.dimensions.width,k=l-s*2;g===d?b.moveTo(w,g).lineTo(l,d):b.moveTo(w,g).lineTo(k,g).arcTo(s,s,0,!1,!x,l-s,g+(x?-s:s)).lineTo(l-s,d+(x?s:-s)).arcTo(s,s,0,!1,x,l,d),u.append("path").attr("class","railroad-line").attr("d",b.build()),h+=f.dimensions.height+this.config.verticalSeparation}return{element:u.node(),dimensions:{width:l,height:a,up:d,down:a-d}}}renderOptional(e,r){const n=this.renderExpression(e,r),i=this.config.arcRadius,a=i*2,s=n.dimensions.width+i*4,o=n.dimensions.height+a,l=e.append("g").attr("class","railroad-optional"),u=i*2,h=a;l.node().appendChild(n.element).setAttribute("transform",`translate(${u}, ${h})`);const f=h+n.dimensions.up,p=new Bl().moveTo(0,f).lineTo(i*2,f);l.append("path").attr("class","railroad-line").attr("d",p.build());const g=new Bl().moveTo(u+n.dimensions.width,f).lineTo(s,f);l.append("path").attr("class","railroad-line").attr("d",g.build());const m=new Bl().moveTo(0,f).arcTo(i,i,0,!1,!1,i,f-i).lineTo(i,i).arcTo(i,i,0,!1,!0,i*2,0).lineTo(s-i*2,0).arcTo(i,i,0,!1,!0,s-i,i).lineTo(s-i,f-i).arcTo(i,i,0,!1,!1,s,f);return l.append("path").attr("class","railroad-line").attr("d",m.build()),{element:l.node(),dimensions:{width:s,height:o,up:f,down:o-f}}}renderRepetition(e,r,n){const i=this.renderExpression(e,r),a=this.config.arcRadius,s=a*2,o=i.dimensions.width+a*4,l=n===0,u=i.dimensions.height+s+(l?s:0),h=e.append("g").attr("class","railroad-repetition"),d=a*2,f=l?s:0;h.node().appendChild(i.element).setAttribute("transform",`translate(${d}, ${f})`);const g=f+i.dimensions.up;h.append("path").attr("class","railroad-line").attr("d",new Bl().moveTo(0,g).lineTo(a*2,g).build()),h.append("path").attr("class","railroad-line").attr("d",new Bl().moveTo(d+i.dimensions.width,g).lineTo(o,g).build());const m=f+i.dimensions.height+a,y=new Bl().moveTo(d+i.dimensions.width,g).arcTo(a,a,0,!1,!0,d+i.dimensions.width+a,g+a).lineTo(d+i.dimensions.width+a,m).arcTo(a,a,0,!1,!0,d+i.dimensions.width,m+a).lineTo(a*2,m+a).arcTo(a,a,0,!1,!0,a,m).lineTo(a,g+a).arcTo(a,a,0,!1,!0,a*2,g);if(h.append("path").attr("class","railroad-line").attr("d",y.build()),l){const v=new Bl().moveTo(0,g).arcTo(a,a,0,!1,!1,a,g-a).lineTo(a,a).arcTo(a,a,0,!1,!0,a*2,0).lineTo(o-a*2,0).arcTo(a,a,0,!1,!0,o-a,a).lineTo(o-a,g-a).arcTo(a,a,0,!1,!1,o,g);h.append("path").attr("class","railroad-line").attr("d",v.build())}return{element:h.node(),dimensions:{width:o,height:u,up:g,down:u-g}}}renderSpecial(e,r){const n=this.measureText("? "+r+" ?"),i=n.width+this.config.padding*2,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-special");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a),s.append("text").attr("x",i/2).attr("y",a/2).text("? "+r+" ?"),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/2}}}renderExpression(e,r){switch(r.type){case"terminal":return this.renderTerminal(e,r.value);case"nonterminal":return this.renderNonTerminal(e,r.name);case"sequence":return this.renderSequence(e,r.elements);case"choice":return this.renderChoice(e,r.alternatives);case"optional":return this.renderOptional(e,r.element);case"repetition":return this.renderRepetition(e,r.element,r.min);case"special":return this.renderSpecial(e,r.text);default:throw new Error(`Unknown node type: ${r.type}`)}}renderRule(e,r){const n=this.svg.append("g").attr("class","railroad-rule").attr("transform",`translate(0, ${r})`),i=e.name+" =",a=this.measureText(i).width+20,s=a+20,o=n.append("g"),l=this.renderExpression(o,e.definition),u=Math.max(20,l.dimensions.up),h=u-l.dimensions.up;return o.attr("transform",`translate(${s}, ${h})`),n.append("g").attr("class","railroad-rule-name-group").append("text").attr("class","railroad-rule-name").attr("x",0).attr("y",u).text(i),n.append("g").attr("class","railroad-start").append("circle").attr("cx",a).attr("cy",u).attr("r",this.config.markerRadius),n.append("g").attr("class","railroad-end").append("circle").attr("cx",s+l.dimensions.width+10).attr("cy",u).attr("r",this.config.markerRadius),n.append("path").attr("class","railroad-line").attr("d",new Bl().moveTo(a+this.config.markerRadius,u).lineTo(s,u).build()),n.append("path").attr("class","railroad-line").attr("d",new Bl().moveTo(s+l.dimensions.width,u).lineTo(s+l.dimensions.width+10-this.config.markerRadius,u).build()),{height:Math.max(40,h+l.dimensions.height+this.config.padding*2),width:s+l.dimensions.width+10+this.config.markerRadius}}renderDiagram(e){let r=this.config.padding,n=0;for(const i of e){const a=this.renderRule(i,r);r+=a.height+this.config.verticalSeparation,n=Math.max(n,a.width)}return{width:n+this.config.padding*2,height:r+this.config.padding}}},T(_x,"RailroadRenderer"),_x),zle=T((t,e,r)=>{Ji(t,e.height,e.width,r),t.attr("viewBox",`0 0 ${e.width} ${e.height}`)},"configureRailroadSvgSize"),aHt=T((t,e,r)=>{he.debug(`[Railroad] Rendering diagram +`+t);try{const n=js(e);n.attr("class","railroad-diagram");const i=lr().railroad,a=(i==null?void 0:i.useMaxWidth)??!0,s=wi.getRules();if(he.debug(`[Railroad] Rendering ${s.length} rules`),s.length===0){he.warn("[Railroad] No rules to render"),zle(n,{height:100,width:200},a);return}const l=new iHt(n,jj()).renderDiagram(s);zle(n,l,a),he.debug("[Railroad] Render complete")}catch(n){throw he.error("[Railroad] Render error:",n),n}},"draw"),VL={draw:aHt},sHt=UX().Railroad.parser.LangiumParser,ty=T(t=>{switch(t.$type){case"RailroadTerminalExpr":return{type:"terminal",value:t.value};case"RailroadNonTerminalExpr":return{type:"nonterminal",name:t.name};case"RailroadSpecialExpr":return{type:"special",text:t.text};case"RailroadSequenceExpr":{const e=t.elements.map(ty);return e.length===1?e[0]:{type:"sequence",elements:e}}case"RailroadChoiceExpr":{const e=t.alternatives.map(ty);return e.length===1?e[0]:{type:"choice",alternatives:e}}case"RailroadOptionalExpr":return{type:"optional",element:ty(t.element)};case"RailroadOneOrMoreExpr":return{type:"repetition",element:ty(t.element),min:1,max:1/0};case"RailroadZeroOrMoreExpr":return{type:"repetition",element:ty(t.element),min:0,max:1/0};default:throw new Error(`Unsupported railroad expression: ${t.$type}`)}},"transformExpression"),oHt=T(t=>({name:t.name,definition:ty(t.definition)}),"transformRule"),lHt=T(t=>{No(t,wi),t.title&&wi.setTitle(t.title),t.rules.map(e=>wi.addRule(oHt(e)))},"populateDb"),cHt={parse:T(t=>{wi.clear(),he.debug("[Railroad Parser] Starting Langium parse");const e=sHt.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new mC(e);const r=e.value;he.debug("[Railroad Parser] Parsed rules:",r.rules.length),lHt(r),he.debug("[Railroad Parser] Parse complete")},"parse"),parser:{yy:wi}},uHt={parser:cHt,db:wi,renderer:VL,styles:zL};const hHt=Object.freeze(Object.defineProperty({__proto__:null,diagram:uHt},Symbol.toStringTag,{value:"Module"}));var dHt=YX().RailroadEbnf.parser.LangiumParser,GE=T(t=>{const e=t.alternatives.map(fHt);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformChoice"),fHt=T(t=>{const e=t.elements.map(gHt);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),AIe=T(t=>{switch(t.$type){case"EbnfTerminal":return{type:"terminal",value:t.value};case"EbnfNonTerminal":return{type:"nonterminal",name:t.name};case"EbnfSpecial":return{type:"special",text:t.text};case"EbnfGroup":return GE(t.element);case"EbnfOptional":return{type:"optional",element:GE(t.element)};case"EbnfRepetition":return{type:"repetition",element:GE(t.element),min:0,max:1/0};default:throw new Error(`Unsupported EBNF primary node: ${t.$type}`)}},"transformPrimary"),pHt=T((t,e)=>{switch(e.$type){case"EbnfOptionalPostfix":return{type:"optional",element:t};case"EbnfZeroOrMorePostfix":return{type:"repetition",element:t,min:0,max:1/0};case"EbnfOneOrMorePostfix":return{type:"repetition",element:t,min:1,max:1/0};case"EbnfExceptionPostfix":return{type:"sequence",elements:[t,{type:"terminal",value:"-"},AIe(e.except)]};default:throw new Error(`Unsupported EBNF postfix node: ${e.$type}`)}},"transformPostfix"),gHt=T(t=>t.postfixes.reduce((e,r)=>pHt(e,r),AIe(t.base)),"transformTerm"),mHt=T(t=>({name:t.name,definition:GE(t.definition)}),"transformRule"),yHt=T(t=>{No(t,wi),t.title&&wi.setTitle(t.title),t.rules.map(e=>wi.addRule(mHt(e)))},"populateDb"),vHt={parse:T(t=>{wi.clear(),he.debug("[EBNF Parser] Starting Langium parse");const e=dHt.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new mC(e);const r=e.value;he.debug("[EBNF Parser] Parsed rules:",r.rules.length),yHt(r),he.debug("[EBNF Parser] Parse complete")},"parse"),parser:{yy:wi}},bHt={parser:vHt,db:wi,renderer:VL,styles:zL};const xHt=Object.freeze(Object.defineProperty({__proto__:null,diagram:bHt},Symbol.toStringTag,{value:"Module"}));var THt=XX().RailroadAbnf.parser.LangiumParser,zV=T(t=>{const e=t.alternatives.map(wHt);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformAlternation"),wHt=T(t=>{const e=t.elements.map(CHt);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformConcatenation"),kHt=T(t=>{if(t.includes("*")){const[r,n]=t.split("*"),i=r?parseInt(r,10):0,a=n?parseInt(n,10):1/0;return{min:i,max:a}}const e=parseInt(t,10);return{min:e,max:e}},"parseRepeat"),CHt=T(t=>{const e=SHt(t.primary);if(!t.repeat)return e;const{min:r,max:n}=kHt(t.repeat);return r===0&&n===1?{type:"optional",element:e}:{type:"repetition",element:e,min:r,max:n}},"transformElement"),SHt=T(t=>{switch(t.$type){case"AbnfStringLiteral":return{type:"terminal",value:t.value};case"AbnfNumVal":return{type:"terminal",value:t.value};case"AbnfRuleName":return{type:"nonterminal",name:t.name};case"AbnfGroup":return zV(t.element);case"AbnfOptionalGroup":return{type:"optional",element:zV(t.element)};default:throw new Error(`Unsupported ABNF primary node: ${t.$type}`)}},"transformPrimary"),EHt=T(t=>({name:t.name,definition:zV(t.definition)}),"transformRule"),AHt=T(t=>{No(t,wi),t.title&&wi.setTitle(t.title),t.rules.map(e=>wi.addRule(EHt(e)))},"populateDb"),_Ht={parse:T(t=>{wi.clear(),he.debug("[ABNF Parser] Starting Langium parse");const e=THt.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new mC(e);const r=e.value;he.debug("[ABNF Parser] Parsed rules:",r.rules.length),AHt(r),he.debug("[ABNF Parser] Parse complete")},"parse"),parser:{yy:wi}},RHt={parser:_Ht,db:wi,renderer:VL,styles:zL};const LHt=Object.freeze(Object.defineProperty({__proto__:null,diagram:RHt},Symbol.toStringTag,{value:"Module"}));var MHt=jX().RailroadPeg.parser.LangiumParser,_Ie=T(t=>{const e=t.alternatives.map(IHt);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformOrderedChoice"),IHt=T(t=>{const e=t.elements.map(NHt);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),NHt=T(t=>{const e=DHt(t.suffix);return t.operator?{type:"special",text:t.operator==="&"?`&${Vle(e)}`:`!${Vle(e)}`}:e},"transformPrefix"),Vle=T(t=>{switch(t.type){case"terminal":return`"${t.value}"`;case"nonterminal":return t.name;case"special":return t.text;default:return"(...)"}},"nodeToLabel"),DHt=T(t=>{const e=OHt(t.primary);if(!t.operator)return e;switch(t.operator){case"?":return{type:"optional",element:e};case"*":return{type:"repetition",element:e,min:0,max:1/0};case"+":return{type:"repetition",element:e,min:1,max:1/0};default:throw new Error(`Unsupported PEG suffix operator: ${t.operator}`)}},"transformSuffix"),OHt=T(t=>{switch(t.$type){case"PegLiteral":return{type:"terminal",value:t.value};case"PegIdentifier":return{type:"nonterminal",name:t.name};case"PegGroup":return _Ie(t.element);case"PegAny":return{type:"special",text:t.dot};default:throw new Error(`Unsupported PEG primary node: ${t.$type}`)}},"transformPrimary"),$Ht=T(t=>({name:t.name,definition:_Ie(t.definition)}),"transformRule"),PHt=T(t=>{No(t,wi),t.title&&wi.setTitle(t.title),t.rules.map(e=>wi.addRule($Ht(e)))},"populateDb"),BHt={parse:T(t=>{wi.clear(),he.debug("[PEG Parser] Starting Langium parse");const e=MHt.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new mC(e);const r=e.value;he.debug("[PEG Parser] Parsed rules:",r.rules.length),PHt(r),he.debug("[PEG Parser] Parse complete")},"parse"),parser:{yy:wi}},FHt={parser:BHt,db:wi,renderer:VL,styles:zL};const zHt=Object.freeze(Object.defineProperty({__proto__:null,diagram:FHt},Symbol.toStringTag,{value:"Module"}));var VHt=1;function Kj(){if(!(typeof globalThis>"u"))return globalThis}T(Kj,"getCaptureGlobal");function RIe(){var t;return!!((t=Kj())!=null&&t.mermaidCaptureSizes)}T(RIe,"shouldCaptureSizes");function LIe(){return typeof location>"u"?"browser-dev":`${location.pathname}${location.search}`}T(LIe,"capturedFromLocation");function MIe(t,e){const r=Kj();if(!r)return;const n=e.node(),i=(n&&"ownerSVGElement"in n?n.ownerSVGElement:null)??n,a=(i==null?void 0:i.id)??"(unknown)";r.mermaidCapturedSizes??(r.mermaidCapturedSizes=[]);const s={svgId:a,sizes:t};r.mermaidCapturedSizes.push(s),r.mermaidLastCapturedSizes=s}T(MIe,"emitCapturedSizes");function IIe(t,e){const r=[];for(const n of e.nodes)n.isGroup||r.push({id:n.id,width:n.width??0,height:n.height??0});r.length!==0&&MIe({metadata:{captureVersion:VHt,capturedAt:new Date().toISOString(),capturedFrom:LIe()},nodes:r},t)}T(IIe,"captureNodeSizes");const HHt=Object.freeze(Object.defineProperty({__proto__:null,captureNodeSizes:IIe,shouldCaptureSizes:RIe},Symbol.toStringTag,{value:"Module"})),GHt=Object.freeze(Object.defineProperty({__proto__:null,InfoModule:U9e,createInfoServices:Y9e},Symbol.toStringTag,{value:"Module"})),qHt=Object.freeze(Object.defineProperty({__proto__:null,PacketModule:X9e,createPacketServices:j9e},Symbol.toStringTag,{value:"Module"})),WHt=Object.freeze(Object.defineProperty({__proto__:null,PieModule:K9e,createPieServices:Z9e},Symbol.toStringTag,{value:"Module"})),UHt=Object.freeze(Object.defineProperty({__proto__:null,TreeViewModule:Q9e,createTreeViewServices:J9e},Symbol.toStringTag,{value:"Module"})),YHt=Object.freeze(Object.defineProperty({__proto__:null,ArchitectureModule:e_e,createArchitectureServices:t_e},Symbol.toStringTag,{value:"Module"})),XHt=Object.freeze(Object.defineProperty({__proto__:null,GitGraphModule:q9e,createGitGraphServices:W9e},Symbol.toStringTag,{value:"Module"})),jHt=Object.freeze(Object.defineProperty({__proto__:null,EventModelingModule:n_e,createEventModelingServices:i_e},Symbol.toStringTag,{value:"Module"})),KHt=Object.freeze(Object.defineProperty({__proto__:null,RadarModule:M9e,createRadarServices:I9e},Symbol.toStringTag,{value:"Module"})),ZHt=Object.freeze(Object.defineProperty({__proto__:null,RailroadModule:N9e,createRailroadServices:UX},Symbol.toStringTag,{value:"Module"})),QHt=Object.freeze(Object.defineProperty({__proto__:null,RailroadEbnfModule:D9e,createRailroadEbnfServices:YX},Symbol.toStringTag,{value:"Module"})),JHt=Object.freeze(Object.defineProperty({__proto__:null,RailroadAbnfModule:O9e,createRailroadAbnfServices:XX},Symbol.toStringTag,{value:"Module"})),eGt=Object.freeze(Object.defineProperty({__proto__:null,RailroadPegModule:$9e,createRailroadPegServices:jX},Symbol.toStringTag,{value:"Module"})),tGt=Object.freeze(Object.defineProperty({__proto__:null,TreemapModule:B9e,createTreemapServices:F9e},Symbol.toStringTag,{value:"Module"})),rGt=Object.freeze(Object.defineProperty({__proto__:null,WardleyModule:z9e,createWardleyServices:V9e},Symbol.toStringTag,{value:"Module"})),nGt=Object.freeze(Object.defineProperty({__proto__:null,CynefinModule:H9e,createCynefinServices:G9e},Symbol.toStringTag,{value:"Module"})); diff --git a/apps/ios/EditorSource/src/main.ts b/apps/ios/EditorSource/src/main.ts index 065da02e..b1510938 100644 --- a/apps/ios/EditorSource/src/main.ts +++ b/apps/ios/EditorSource/src/main.ts @@ -64,6 +64,8 @@ type BridgeMessage = | { type: "loadResource"; requestId: string; source: string } | { type: "resourcePress"; targetJson: string } | { type: "imagePreview"; source: string; alt: string } + | { type: "pickImage" } + | { type: "searchResult"; count: number; index: number } | { type: "activeFlags"; flags: number } | { type: "log"; message: string } | { type: "error"; message: string }; @@ -123,6 +125,8 @@ type ConfigureOptions = { const startedAt = performance.now(); let mode: "viewer" | "editor" = "viewer"; +let locale = "zh-CN"; +let currentPlaceholder = "开始输入…"; let suppressChange = false; const resourceResolvers = new Map void>(); let resourceSeq = 0; @@ -631,9 +635,11 @@ const editor = new Editor({ editable: false, content: { type: "doc", content: [{ type: "paragraph" }] }, onUpdate: ({ editor: ed }) => { + refreshToolbarState(); if (suppressChange || mode !== "editor") return; emitChange(ed); }, + onSelectionUpdate: () => refreshToolbarState(), editorProps: { attributes: { class: "edgeever-prose", @@ -808,17 +814,65 @@ function emitChange(ed: Editor) { } } +type EditorSearchMatch = { from: number; to: number }; + +function getEditorSearchMatches(ed: Editor, query: string): EditorSearchMatch[] { + const needle = query.trim().toLocaleLowerCase(); + if (needle.length === 0) return []; + + const characters: Array<{ char: string; pos: number }> = []; + let previousTextEnd: number | null = null; + ed.state.doc.descendants((node, pos) => { + if (!node.isText || !node.text) return; + if (previousTextEnd !== null && pos > previousTextEnd) { + characters.push({ char: "\u0000", pos: -1 }); + } + for (let index = 0; index < node.text.length; index += 1) { + characters.push({ char: node.text[index] ?? "", pos: pos + index }); + } + previousTextEnd = pos + node.text.length; + }); + + const haystack = characters.map((item) => item.char).join("").toLocaleLowerCase(); + const matches: EditorSearchMatch[] = []; + let index = haystack.indexOf(needle); + while (index !== -1) { + const start = characters[index]; + const end = characters[index + needle.length - 1]; + if (start && end && start.pos >= 0 && end.pos >= 0) { + matches.push({ from: start.pos, to: end.pos + 1 }); + } + index = haystack.indexOf(needle, index + needle.length); + } + return matches; +} + function setToolbarVisible(visible: boolean) { toolbarEl.classList.toggle("editor-mode", visible); toolbarEl.innerHTML = ""; if (!visible) return; const actions: Array<{ id: string; label: string; run: () => void }> = [ + { + id: "image", + label: "▧+", + run: () => post({ type: "pickImage" }), + }, { id: "bold", label: "B", run: () => editor.chain().focus().toggleBold().run() }, { id: "bullet", label: "•", run: () => editor.chain().focus().toggleBulletList().run(), }, + { + id: "indent", + label: "⇥", + run: () => editor.chain().focus().sinkListItem("listItem").run(), + }, + { + id: "outdent", + label: "⇤", + run: () => editor.chain().focus().liftListItem("listItem").run(), + }, { id: "quote", label: "❝", @@ -829,31 +883,47 @@ function setToolbarVisible(visible: boolean) { label: "—", run: () => editor.chain().focus().setHorizontalRule().run(), }, - { - id: "h2", - label: "H2", - run: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), - }, - { - id: "code", - label: "", - run: () => editor.chain().focus().toggleCodeBlock().run(), - }, ]; for (const action of actions) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = action.label; btn.dataset.action = action.id; + const labels: Record = { + image: ["插入图片", "Insert image"], + bold: ["粗体", "Bold"], + bullet: ["项目符号列表", "Bullet list"], + indent: ["增加列表缩进", "Increase list indent"], + outdent: ["减少列表缩进", "Decrease list indent"], + quote: ["引用", "Block quote"], + hr: ["分隔线", "Horizontal rule"], + }; + btn.setAttribute("aria-label", labels[action.id]?.[locale === "en-US" ? 1 : 0] ?? action.id); btn.addEventListener("click", () => { action.run(); - emitChange(editor); + if (action.id !== "image") emitChange(editor); + refreshToolbarState(); }); toolbarEl.appendChild(btn); } + refreshToolbarState(); +} + +function refreshToolbarState() { + const active: Record = { + bold: editor.isActive("bold"), + bullet: editor.isActive("bulletList"), + quote: editor.isActive("blockquote"), + }; + toolbarEl.querySelectorAll("button[data-action]").forEach((button) => { + button.classList.toggle("is-active", active[button.dataset.action ?? ""] ?? false); + }); } async function afterContentSet(theme: "light" | "dark" = "light") { + editorEl.querySelectorAll("[data-placeholder]").forEach((element) => { + element.dataset.placeholder = currentPlaceholder; + }); await hydrateProtectedImages(editorEl); if (mode === "viewer") { await renderMermaidBlocks(editorEl, theme); @@ -873,6 +943,7 @@ export type EdgeEverEditorAPI = { beginImageUpload: (uploadId: string, previewDataUrl: string) => void; completeImageUpload: (uploadId: string, imageUrl: string, alt: string) => void; cancelImageUpload: (uploadId: string) => void; + search: (query: string, requestedIndex: number) => void; }; const api: EdgeEverEditorAPI = { @@ -880,14 +951,17 @@ const api: EdgeEverEditorAPI = { const nextMode = opts.mode === "editor" ? "editor" : "viewer"; const modeChanged = nextMode !== mode; mode = nextMode; + locale = opts.locale === "en-US" ? "en-US" : "zh-CN"; editor.setEditable(mode === "editor"); setToolbarVisible(mode === "editor"); document.documentElement.dataset.theme = opts.theme || "light"; document.body.classList.toggle("viewer-mode", mode === "viewer"); document.body.classList.toggle("editor-mode", mode === "editor"); if (opts.placeholder) { - // placeholder is extension config; update via meta class - editorEl.setAttribute("data-placeholder", opts.placeholder); + currentPlaceholder = opts.placeholder; + editorEl.querySelectorAll("[data-placeholder]").forEach((element) => { + element.dataset.placeholder = currentPlaceholder; + }); } // Match Evernote-style edit entry: focus the surface when entering editor mode. // Combined with setContent's default end selection, caret lands at document end. @@ -900,6 +974,7 @@ const api: EdgeEverEditorAPI = { } }); } + void afterContentSet(opts.theme || "light"); }, setMarkdown(md) { @@ -1028,6 +1103,25 @@ const api: EdgeEverEditorAPI = { img?.remove(); emitChange(editor); }, + + search(query, requestedIndex) { + const matches = getEditorSearchMatches(editor, query); + const index = matches.length > 0 + ? Math.min(Math.max(Number.isFinite(requestedIndex) ? requestedIndex : 0, 0), matches.length - 1) + : 0; + const match = matches[index]; + if (match) { + editor.commands.setTextSelection({ from: match.from, to: match.to }); + try { + const dom = editor.view.domAtPos(match.from).node; + const element = dom instanceof Element ? dom : dom.parentElement; + element?.scrollIntoView({ block: "center", behavior: "smooth" }); + } catch { + /* ignore */ + } + } + post({ type: "searchResult", count: matches.length, index }); + }, }; (window as unknown as { EdgeEverEditor: EdgeEverEditorAPI }).EdgeEverEditor = api; diff --git a/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift b/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift index 5fb3e4b4..8fc4c6aa 100644 --- a/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift +++ b/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift @@ -130,6 +130,9 @@ final class ChromeParityTests: XCTestCase { // Edit is requested via callback; presentation is owned by WorkspaceView (reliable). XCTAssertTrue(src.contains("onEdit"), "detail requests edit via onEdit callback") XCTAssertTrue(src.contains("EditFabButton"), "UIKit FAB so WebView cannot steal taps") + XCTAssertTrue(src.contains("onSearchResult"), "in-note search must receive match count/index") + XCTAssertTrue(src.contains("SharedTipTapRuntime.viewer.search"), "search controls must drive TipTap selection") + XCTAssertTrue(src.contains("ActivityShareView"), "public links must open the iOS system share sheet") XCTAssertTrue(src.contains(".overlay(alignment: .bottomTrailing)"), "FAB overlay above WebView") // UIKit FAB file wires green + a11y id let fabSrc = try readShippedSource("DesignSystem/EditFabButton.swift") @@ -137,6 +140,29 @@ final class ChromeParityTests: XCTestCase { XCTAssertTrue(fabSrc.contains("DetailMemoChrome.editFab") || fabSrc.contains("detailEditFab"), "FAB a11y id") } + func testWorkspaceIncludesAndroidBatchAndWebClipActions() throws { + let view = try readShippedSource("Features/Workspace/WorkspaceView.swift") + let store = try readShippedSource("Features/Workspace/WorkspaceStore.swift") + XCTAssertTrue(view.contains("SelectionMoreSheet")) + XCTAssertTrue(view.contains("全选当前列表")) + XCTAssertTrue(view.contains("WebClipCaptureView")) + XCTAssertTrue(view.contains("WebClipper.build")) + XCTAssertTrue(store.contains("toggleVisibleSelection")) + XCTAssertTrue(store.contains("pinSelection")) + } + + func testEditorBundleBridgeMatchesAndroidToolbarAndTheme() throws { + let source = try readIOSFile("EditorSource/src/main.ts") + let runtime = try readShippedSource("Editor/TipTapWarmPool.swift") + XCTAssertTrue(source.contains("sinkListItem")) + XCTAssertTrue(source.contains("liftListItem")) + XCTAssertTrue(source.contains(#"type: "pickImage""#)) + XCTAssertTrue(source.contains(#"type: "searchResult""#)) + XCTAssertTrue(runtime.contains("session.theme")) + XCTAssertTrue(runtime.contains("session.locale")) + XCTAssertFalse(runtime.contains("locale: 'zh-CN', theme: 'light'")) + } + func testWorkspacePresentsEditFromRootCover() throws { let src = try readShippedSource("Features/Workspace/WorkspaceView.swift") XCTAssertTrue(src.contains("editingMemo"), "workspace holds editing route") @@ -211,4 +237,10 @@ final class ChromeParityTests: XCTestCase { let url = iosRoot.appendingPathComponent("EdgeEver").appendingPathComponent(relativeUnderEdgeEver) return try String(contentsOf: url, encoding: .utf8) } + + private func readIOSFile(_ relativeUnderIOS: String) throws -> String { + let testsDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let iosRoot = testsDir.deletingLastPathComponent().deletingLastPathComponent() + return try String(contentsOf: iosRoot.appendingPathComponent(relativeUnderIOS), encoding: .utf8) + } } diff --git a/apps/ios/Tests/EdgeEverTests/WebClipperTests.swift b/apps/ios/Tests/EdgeEverTests/WebClipperTests.swift new file mode 100644 index 00000000..9881ccdf --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/WebClipperTests.swift @@ -0,0 +1,62 @@ +import XCTest +@testable import EdgeEver + +final class WebClipperTests: XCTestCase { + func testSharedWebURLFindsURLInsideText() { + let payloads = [ShareHandoffStore.SharePayload( + text: "Read this https://example.com/articles/one?from=share today", + url: nil, + title: nil + )] + XCTAssertEqual( + WebClipper.sharedWebURL(from: payloads)?.absoluteString, + "https://example.com/articles/one?from=share" + ) + } + + func testRenderedPageCreatesMarkdownWithResolvedLinksAndImages() throws { + let source = try XCTUnwrap(URL(string: "https://example.com/articles/one")) + let draft = WebClipper.buildRendered( + source, + page: RenderedWebPage( + title: " Example article ", + contentHTML: "

Heading

Hello world.

\"Cover\"Docs", + finalURL: source.absoluteString + ), + capturedAt: Date(timeIntervalSince1970: 0) + ) + + XCTAssertEqual(draft.title, "Example article") + XCTAssertEqual(draft.tagsText, "web-clip") + XCTAssertTrue(draft.contentMarkdown.contains("## Heading")) + XCTAssertTrue(draft.contentMarkdown.contains("Hello **world**.")) + XCTAssertTrue(draft.contentMarkdown.contains("![Cover](https://example.com/cover.png)")) + XCTAssertTrue(draft.contentMarkdown.contains("[Docs](https://example.com/docs)")) + XCTAssertTrue(draft.contentMarkdown.contains("来源:[https://example.com/articles/one]")) + } + + func testWeChatRenderedPageUsesWechatTag() throws { + let source = try XCTUnwrap(URL(string: "https://mp.weixin.qq.com/s/example")) + let draft = WebClipper.buildRendered( + source, + page: RenderedWebPage( + title: "微信文章", + contentHTML: "

正文

", + finalURL: source.absoluteString + ) + ) + XCTAssertEqual(draft.tagsText, "web-clip, wechat") + XCTAssertTrue(draft.contentMarkdown.contains("正文")) + } + + func testEmptyRenderedBodyFallsBackButKeepsRenderedTitle() throws { + let source = try XCTUnwrap(URL(string: "https://example.com/a")) + let draft = WebClipper.buildRendered( + source, + page: RenderedWebPage(title: "Known title", contentHTML: "", finalURL: source.absoluteString) + ) + XCTAssertEqual(draft.title, "Known title") + XCTAssertTrue(draft.contentMarkdown.contains("正文暂时无法抓取")) + XCTAssertTrue(draft.contentMarkdown.contains(source.absoluteString)) + } +}