feat: align iOS mobile parity

This commit is contained in:
tianma-if committed 2026-08-09 22:40:55 +08:00
1 parent 038303f784
commit 8af75fc0de
19 files changed
+1805 -836

No files matched your search

@@ -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 = "<group>"; };
12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncProtocol.swift; sourceTree = "<group>"; };
12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipTapWebView.swift; sourceTree = "<group>"; };
13105CFE9131425A1C7E4BF0 /* WebClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebClipper.swift; sourceTree = "<group>"; };
1606EF8ADEA4921421EB0690 /* MobileUIParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileUIParityTests.swift; sourceTree = "<group>"; };
17A2092411EFC3765B97DF88 /* SystemImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImagePicker.swift; sourceTree = "<group>"; };
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 = "<group>"; };
59C38DBB41BA706C678AB9BD /* MemoEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoEditViewModel.swift; sourceTree = "<group>"; };
5CB54673617E14F28B7608BD /* Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typography.swift; sourceTree = "<group>"; };
5D7D3262D1E3E40E38361E84 /* WebClipperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebClipperTests.swift; sourceTree = "<group>"; };
62B460B6BFE4CFB23073C7E1 /* SyncOutboxRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncOutboxRepository.swift; sourceTree = "<group>"; };
6CB16CA1499554463E1927E7 /* PreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesStore.swift; sourceTree = "<group>"; };
73FA569C71080B6F9989AA03 /* ApiTokensView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiTokensView.swift; sourceTree = "<group>"; };
@@ -243,6 +247,7 @@
isa = PBXGroup;
children = (
D045B340A984E17B3B0047BF /* ShareHandoffStore.swift */,
13105CFE9131425A1C7E4BF0 /* WebClipper.swift */,
);
path = Share;
sourceTree = "<group>";
@@ -483,6 +488,7 @@
E6159DBF27CC86920005E25D /* SyncProtocolTests.swift */,
8A536119D29812EF11DAEAA8 /* TipTapContentSourceTests.swift */,
41B15501968A29C372E7B3BB /* URLNormalizeTests.swift */,
5D7D3262D1E3E40E38361E84 /* WebClipperTests.swift */,
);
path = EdgeEverTests;
sourceTree = "<group>";
@@ -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 */,
);
@@ -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
+58 -10
View File
@@ -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
}
+11 -1
View File
@@ -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
)
)
@@ -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: #"<!--[\s\S]*?-->"#) { _ in "" }
value = replace(
value,
pattern: #"<(script|style|noscript|template|iframe|svg|canvas)\b[^>]*>[\s\S]*?</\1\s*>"#
) { _ in "" }
value = replace(value, pattern: #"<img\b[^>]*>"#) { 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: #"<a\b[^>]*>([\s\S]*?)</a\s*>"#) { match in
let href = attribute(match, "href")
let label = normalizedText(stripTags(capture(match, pattern: #"<a\b[^>]*>([\s\S]*?)</a\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: #"<h([1-6])\b[^>]*>"#) { match in
let level = Int(capture(match, pattern: #"<h([1-6])\b"#) ?? "1") ?? 1
return "\n\n\(String(repeating: "#", count: level)) "
}
let substitutions: [(String, String)] = [
(#"</h[1-6]\s*>"#, "\n\n"), (#"<br\s*/?>"#, "\n"),
(#"<hr\b[^>]*>"#, "\n\n---\n\n"), (#"<li\b[^>]*>"#, "\n- "),
(#"</li\s*>"#, "\n"), (#"<(strong|b)\b[^>]*>"#, "**"),
(#"</(strong|b)\s*>"#, "**"), (#"<(em|i)\b[^>]*>"#, "*"),
(#"</(em|i)\s*>"#, "*"), (#"<code\b[^>]*>"#, "`"),
(#"</code\s*>"#, "`"), (#"</?blockquote\b[^>]*>"#, "\n\n> "),
(#"</?(article|aside|div|figure|figcaption|footer|header|main|p|section|table|tbody|td|th|thead|tr)\b[^>]*>"#, "\n\n"),
(#"</?[a-z][a-z0-9-]*\b[^>]*>"#, ""),
]
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: #"<meta\b[^>]*>"#)
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: #"<title\b[^>]*>([\s\S]*?)</title\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]*?)</\(tag)\\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
}
}
@@ -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)
}
}
@@ -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")
}
@@ -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 {
@@ -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 {
@@ -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
)
@@ -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
}
}
@@ -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<Content: View>(
title: String,
icon: String,
@@ -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 {
@@ -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]
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+107 -13
View File
@@ -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<string, (dataUrl: string | null) => 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<string, [string, string]> = {
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<string, boolean> = {
bold: editor.isActive("bold"),
bullet: editor.isActive("bulletList"),
quote: editor.isActive("blockquote"),
};
toolbarEl.querySelectorAll<HTMLButtonElement>("button[data-action]").forEach((button) => {
button.classList.toggle("is-active", active[button.dataset.action ?? ""] ?? false);
});
}
async function afterContentSet(theme: "light" | "dark" = "light") {
editorEl.querySelectorAll<HTMLElement>("[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<HTMLElement>("[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;
@@ -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)
}
}
@@ -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: "<h2>Heading</h2><p>Hello <strong>world</strong>.</p><img data-src=\"/cover.png\" alt=\"Cover\"><a href=\"/docs\">Docs</a>",
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: "<p>正文</p>",
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))
}
}