feat: align iOS mobile parity
This commit is contained in:
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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)\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
+356
-356
File diff suppressed because it is too large.
Load diff
@@ -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(""))
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user