From aaa8d477991eebc4810dddf82d57e2706b096ee4 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 7 Aug 2026 12:19:14 +0800 Subject: [PATCH] fix(ios): harden edit/sync so notes keep text, order, and Markdown rendering Rebase stale outbox revisions, leave edit onto the list without flashing detail, treat TipTap JSON as structural truth for image order while deriving markdown for sync, and force the detail viewer through setMarkdown so flattened contentJson no longer kills headings/lists. --- apps/ios/EdgeEver.xcodeproj/project.pbxproj | 8 + .../Data/Database/SyncOutboxRepository.swift | 4 +- .../EdgeEver/Data/Sync/OutboxFlusher.swift | 73 ++- .../EdgeEver/Editor/TipTapContentSource.swift | 67 +++ apps/ios/EdgeEver/Editor/TipTapWarmPool.swift | 27 +- .../Features/Workspace/MemoCreateCommit.swift | 5 +- .../Features/Workspace/MemoEditView.swift | 422 ++++++++++++++---- .../Features/Workspace/WorkspaceView.swift | 26 +- .../EdgeEverTests/EditorBundleTests.swift | 17 +- .../TipTapContentSourceTests.swift | 192 ++++++++ 10 files changed, 718 insertions(+), 123 deletions(-) create mode 100644 apps/ios/EdgeEver/Editor/TipTapContentSource.swift create mode 100644 apps/ios/Tests/EdgeEverTests/TipTapContentSourceTests.swift diff --git a/apps/ios/EdgeEver.xcodeproj/project.pbxproj b/apps/ios/EdgeEver.xcodeproj/project.pbxproj index d2998af5..0a7d1ec2 100644 --- a/apps/ios/EdgeEver.xcodeproj/project.pbxproj +++ b/apps/ios/EdgeEver.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 1510CEE8BDDBD1710C0CE88C /* SessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 574912976CB43DD9821678EE /* SessionStore.swift */; }; 1F8E6268C9DA330CA236B36F /* SyncProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6159DBF27CC86920005E25D /* SyncProtocolTests.swift */; }; 2581F6050C02A4A36340CC0D /* ShareHandoffStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D045B340A984E17B3B0047BF /* ShareHandoffStore.swift */; }; + 265DB48114144779A7C807F7 /* TipTapContentSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE1D4EB4B51C66E9CD72FF7F /* TipTapContentSource.swift */; }; 2A07555B54946A177E1ECFF2 /* Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB54673617E14F28B7608BD /* Typography.swift */; }; 2E185C3C27DA3A729F2734B4 /* SyncOutboxRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62B460B6BFE4CFB23073C7E1 /* SyncOutboxRepository.swift */; }; 33330C89D977D3903F091165 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B812AD3502D649CE7B84DEBE /* LoginView.swift */; }; @@ -31,6 +32,7 @@ 4A2DD0673B81D32FA9E5F9DB /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F590FE9371571DC7456154 /* Theme.swift */; }; 4A771542C5AD2E6A907187AD /* OutboxFlusher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8720E0D6F00D1509B76A374B /* OutboxFlusher.swift */; }; 4BA97CD264D0929B20ED19DB /* TipTapWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */; }; + 4EC901852EDA4BC001717747 /* TipTapContentSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A536119D29812EF11DAEAA8 /* TipTapContentSourceTests.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 */; }; @@ -133,6 +135,7 @@ 8691859094F8C08AE49FE27D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 86F590FE9371571DC7456154 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 8720E0D6F00D1509B76A374B /* OutboxFlusher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxFlusher.swift; sourceTree = ""; }; + 8A536119D29812EF11DAEAA8 /* TipTapContentSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipTapContentSourceTests.swift; sourceTree = ""; }; 94DF0091B1FC2783AEC2D9F6 /* EdgeEverTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = EdgeEverTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9799115E572F9AF0B59A5AD4 /* WorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStore.swift; sourceTree = ""; }; 9B02E8D28111E1E732F3BF36 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; @@ -140,6 +143,7 @@ A375D81573943A98C5732F81 /* MobileUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileUI.swift; sourceTree = ""; }; AB02424D0412FE36C4BCD116 /* index.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = index.html; sourceTree = ""; }; AD904708FC1AD8971439BD90 /* JSONCoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoding.swift; sourceTree = ""; }; + AE1D4EB4B51C66E9CD72FF7F /* TipTapContentSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipTapContentSource.swift; sourceTree = ""; }; B1D94BB61519CA2A345D99C1 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; B30C95F4EABCD08555D97C6B /* NotesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesListView.swift; sourceTree = ""; }; B812AD3502D649CE7B84DEBE /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; @@ -410,6 +414,7 @@ children = ( F9182F742B06FB1713E27C2A /* ResourceActions.swift */, EA9305413739F81EA39395F7 /* ResourceBlobStore.swift */, + AE1D4EB4B51C66E9CD72FF7F /* TipTapContentSource.swift */, 36831A94478123A1EE9BB538 /* TipTapWarmPool.swift */, 12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */, ); @@ -434,6 +439,7 @@ 82E057D0906932A00DF62EBB /* MemoCreateCommitTests.swift */, 1606EF8ADEA4921421EB0690 /* MobileUIParityTests.swift */, E6159DBF27CC86920005E25D /* SyncProtocolTests.swift */, + 8A536119D29812EF11DAEAA8 /* TipTapContentSourceTests.swift */, 41B15501968A29C372E7B3BB /* URLNormalizeTests.swift */, ); path = EdgeEverTests; @@ -583,6 +589,7 @@ 511A8DCB204AFCB2880AE51E /* MemoCreateCommitTests.swift in Sources */, A63E64622A06DF3842908A66 /* MobileUIParityTests.swift in Sources */, 1F8E6268C9DA330CA236B36F /* SyncProtocolTests.swift in Sources */, + 4EC901852EDA4BC001717747 /* TipTapContentSourceTests.swift in Sources */, 0D67F968CAFCC17F28A5555C /* URLNormalizeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -628,6 +635,7 @@ 2E185C3C27DA3A729F2734B4 /* SyncOutboxRepository.swift in Sources */, 5F5EC9565A02FAFDD5D7B54C /* SyncProtocol.swift in Sources */, 4A2DD0673B81D32FA9E5F9DB /* Theme.swift in Sources */, + 265DB48114144779A7C807F7 /* TipTapContentSource.swift in Sources */, 1186BE3109942FCBAB59E6B4 /* TipTapWarmPool.swift in Sources */, 4BA97CD264D0929B20ED19DB /* TipTapWebView.swift in Sources */, 2A07555B54946A177E1ECFF2 /* Typography.swift in Sources */, diff --git a/apps/ios/EdgeEver/Data/Database/SyncOutboxRepository.swift b/apps/ios/EdgeEver/Data/Database/SyncOutboxRepository.swift index d588928e..71057b87 100644 --- a/apps/ios/EdgeEver/Data/Database/SyncOutboxRepository.swift +++ b/apps/ios/EdgeEver/Data/Database/SyncOutboxRepository.swift @@ -123,12 +123,14 @@ final class SyncOutboxRepository: @unchecked Sendable { func flushableItems(scope: String, now: Date = Date()) throws -> [OutboxItem] { let nowString = ISO8601DateFormatter.edgeEver.string(from: now) return try dbQueue.read { db in + // Include `conflict` so a rebase retry can clear stale-base conflicts + // (expectedRevision lag after a successful prior sync). let rows = try Row.fetchAll( db, sql: """ SELECT * FROM mobile_sync_outbox WHERE scope = ? - AND status IN ('pending', 'error', 'syncing') + AND status IN ('pending', 'error', 'syncing', 'conflict') AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY created_at ASC """, diff --git a/apps/ios/EdgeEver/Data/Sync/OutboxFlusher.swift b/apps/ios/EdgeEver/Data/Sync/OutboxFlusher.swift index 908e5205..a88ce96d 100644 --- a/apps/ios/EdgeEver/Data/Sync/OutboxFlusher.swift +++ b/apps/ios/EdgeEver/Data/Sync/OutboxFlusher.swift @@ -84,23 +84,62 @@ actor OutboxFlusher { ) case .memoUpdate: let payload = try item.updatePayload() - let editSession = try await client.createMemoEditSession(memoId: item.memoId) - if editSession.baseRevision != payload.expectedRevision - || editSession.baseContentHash != payload.expectedContentHash - { - throw APIError(status: 409, code: "revision_conflict", message: "Note changed before the offline draft could sync.") - } - return try await client.updateMemo( - id: item.memoId, - expectedRevision: payload.expectedRevision, - expectedContentHash: payload.expectedContentHash, - editSessionId: editSession.id, - notebookId: payload.notebookId, - title: payload.title, - isPinned: nil, - contentMarkdown: payload.contentMarkdown, - tags: payload.tags - ) + return try await applyMemoUpdate(memoId: item.memoId, payload: payload, allowRebase: true) } } + + /// Push a memo update. If the local expected base is stale (common after a prior + /// successful sync that didn't refresh the edit session), rebase once onto the + /// current server base and retry — local draft content wins. + private func applyMemoUpdate( + memoId: String, + payload: MemoUpdatePayload, + allowRebase: Bool + ) async throws -> MemoDetail { + let editSession = try await client.createMemoEditSession(memoId: memoId) + let baseMatches = editSession.baseRevision == payload.expectedRevision + && editSession.baseContentHash == payload.expectedContentHash + + if !baseMatches { + if allowRebase { + #if DEBUG + NSLog( + "OutboxFlusher: rebasing update memo=%@ localRev=%d serverRev=%d", + memoId, + payload.expectedRevision, + editSession.baseRevision + ) + #endif + // Use the live edit session base (already the server tip). + return try await client.updateMemo( + id: memoId, + expectedRevision: editSession.baseRevision, + expectedContentHash: editSession.baseContentHash, + editSessionId: editSession.id, + notebookId: payload.notebookId, + title: payload.title, + isPinned: nil, + contentMarkdown: payload.contentMarkdown, + tags: payload.tags + ) + } + throw APIError( + status: 409, + code: "revision_conflict", + message: "Note changed before the offline draft could sync." + ) + } + + return try await client.updateMemo( + id: memoId, + expectedRevision: payload.expectedRevision, + expectedContentHash: payload.expectedContentHash, + editSessionId: editSession.id, + notebookId: payload.notebookId, + title: payload.title, + isPinned: nil, + contentMarkdown: payload.contentMarkdown, + tags: payload.tags + ) + } } diff --git a/apps/ios/EdgeEver/Editor/TipTapContentSource.swift b/apps/ios/EdgeEver/Editor/TipTapContentSource.swift new file mode 100644 index 00000000..d30f8bc8 --- /dev/null +++ b/apps/ios/EdgeEver/Editor/TipTapContentSource.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Pure policy for which TipTap payload to push (JSON vs Markdown). +/// Extracted so unit tests can lock viewer Markdown rendering without a simulator UI path. +enum TipTapContentSource: Sendable { + struct Decision: Equatable, Sendable { + var useJSON: Bool + var payload: String + var fingerprint: String + } + + private static let emptyStub = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}" + + static func resolve(mode: TipTapMode, documentJSON: String, markdown: String) -> Decision { + let json = documentJSON.trimmingCharacters(in: .whitespacesAndNewlines) + let mdTrim = markdown.trimmingCharacters(in: .whitespacesAndNewlines) + + // Detail/viewer: always prefer contentMarkdown via setMarkdown. + // Local contentJson is often a flattened TipTap tree after edit/sync round-trips. + if mode == .viewer, !mdTrim.isEmpty { + return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)") + } + + // Editor: open from markdown when it encodes richer structure than JSON + // (demo / web-authored notes), so the user does not edit flattened plain text. + if mode == .editor, !mdTrim.isEmpty, markdownIsStructurallyRicher(markdown, thanJSON: json) { + return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)") + } + + let useJSON = !json.isEmpty && json != emptyStub + if useJSON { + return Decision(useJSON: true, payload: documentJSON, fingerprint: "json:\(json)") + } + return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)") + } + + static func markdownIsStructurallyRicher(_ markdown: String, thanJSON json: String) -> Bool { + let md = structureScore(markdown: markdown) + let js = structureScore(json: json) + return md >= 2 && md > js + } + + static func structureScore(markdown: String) -> Int { + var score = 0 + if markdown.contains("```") { score += 3 } + if markdown.range(of: #"(?m)^#{1,6}\s+\S"#, options: .regularExpression) != nil { score += 3 } + if markdown.contains("|") && markdown.contains("\n") { score += 2 } + if markdown.range(of: #"(?m)^(\s*[-*+]|\s*\d+\.)\s+\S"#, options: .regularExpression) != nil { score += 2 } + if markdown.range(of: #"(?m)^>\s+\S"#, options: .regularExpression) != nil { score += 1 } + if markdown.contains("**") || markdown.contains("__") { score += 1 } + if markdown.contains("![") { score += 1 } + return score + } + + static func structureScore(json: String) -> Int { + guard !json.isEmpty else { return 0 } + var score = 0 + if json.contains("\"heading\"") { score += 3 } + if json.contains("\"table\"") || json.contains("\"tableRow\"") { score += 2 } + if json.contains("\"codeBlock\"") { score += 3 } + if json.contains("\"bulletList\"") || json.contains("\"orderedList\"") { score += 2 } + if json.contains("\"blockquote\"") { score += 1 } + if json.contains("\"bold\"") || json.contains("\"italic\"") { score += 1 } + if json.contains("\"type\":\"image\"") || json.contains("\"type\": \"image\"") { score += 1 } + return score + } +} diff --git a/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift b/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift index a1289632..ce6b46d1 100644 --- a/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift +++ b/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift @@ -115,9 +115,10 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD } func bind(_ newSession: TipTapSession) { + let previousMode = session?.mode let previousFingerprint = lastPushedJSON session = newSession - let fp = contentFingerprint(newSession).fingerprint + let fp = contentDecision(newSession).fingerprint let isNewDocument = fp != previousFingerprint && fp != lastEditorEmittedFingerprint if fp != previousFingerprint { @@ -125,8 +126,10 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD hydrateGeneration &+= 1 // Do not zero bodyReadyGeneration here — pushContent / skip path will notify. } + let modeChanged = previousMode != nil && previousMode != newSession.mode applyMode() - pushContentIfNeeded(force: false) + // Mode switch (editor ↔ viewer) must re-push: viewer prefers markdown, editor prefers JSON. + pushContentIfNeeded(force: modeChanged) // Open-edit only: first time this document is shown in the editor, focus end once. // Never refocus on SwiftUI re-binds caused by typing (same emitted fingerprint). if newSession.mode == .editor, isNewDocument { @@ -308,19 +311,19 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD webView.evaluateJavaScript(js, completionHandler: nil) } - private func contentFingerprint(_ session: TipTapSession) -> (fingerprint: String, useJSON: Bool) { - let emptyStub = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}" - let json = session.documentJSON.trimmingCharacters(in: .whitespacesAndNewlines) - let useJSON = !json.isEmpty && json != emptyStub - if useJSON { - return ("json:\(json)", true) - } - return ("md:\(session.markdown)", false) + private func contentDecision(_ session: TipTapSession) -> TipTapContentSource.Decision { + TipTapContentSource.resolve( + mode: session.mode, + documentJSON: session.documentJSON, + markdown: session.markdown + ) } func pushContentIfNeeded(force: Bool = false) { guard ready, let session else { return } - let (fingerprint, useJSON) = contentFingerprint(session) + let decision = contentDecision(session) + let fingerprint = decision.fingerprint + let useJSON = decision.useJSON let gen = contentGeneration // Already showing this document (including editor-originated typing updates). @@ -340,7 +343,7 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD } let fn = useJSON ? "setDocumentFromJSON" : "setMarkdown" - let payload = useJSON ? session.documentJSON : session.markdown + let payload = decision.payload let js = TipTapResourceLoader.jsCall(fn: fn, arg: payload) // Capture callback now — detach may nil session callbacks before the JS completion runs. let bodyReadyCb = session.onBodyReady diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift b/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift index e01a51dd..72173919 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift @@ -76,6 +76,9 @@ enum MemoCreateCommit { return .updatedMaterialized(memoId: serverId) } + // Use mirror base (server tip). Stale caller expectedRevision causes 409 conflicts. + let rev = memo.revision + let hash = memo.contentHash memo.title = displayTitle memo.contentMarkdown = contentMarkdown memo.contentText = contentMarkdown @@ -88,8 +91,6 @@ enum MemoCreateCommit { } try mirror.upsertMemo(scope: scope, memo: memo) - let rev = expectedRevision ?? memo.revision - let hash = expectedContentHash ?? memo.contentHash try outbox.enqueueUpdate( scope: scope, payload: MemoUpdatePayload( diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift index 7c4baa23..d349c86a 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift @@ -14,6 +14,9 @@ struct MemoEditView: View { @Environment(\.dismiss) private var dismiss let mode: MemoEditMode + /// When set (edit-from-detail), close by popping to the list under the cover first — + /// never `dismiss()` onto a still-pushed detail page. + var onLeaveToList: (() -> Void)? = nil @State private var title = "" @State private var tagsText = "" @@ -296,22 +299,15 @@ struct MemoEditView: View { baseURL: env.session.session.map { URL(string: $0.baseUrl) } ?? nil, token: env.session.session?.token, onChange: { md, json in - guard contentHydrated, !isUploading else { return } - // @tiptap/markdown sometimes omits image nodes; never let a bridge - // payload erase protected image refs we already inserted. - var nextMd = md - let resourceRefs = Self.protectedResourceRefs(in: contentMarkdown) - for ref in resourceRefs where !nextMd.contains(ref) { - nextMd += "\n\n![](\(ref))\n" + guard contentHydrated else { return } + // Always accept editor truth, even mid-upload (save stays gated). + // @tiptap/markdown can drop text or images — reconcile carefully. + applyEditorPayload(markdown: md, json: json) + if !isUploading { + markDirtyAndScheduleSave() + } else { + isDirty = true } - contentMarkdown = nextMd - if !json.isEmpty { - contentJSON = json - } - for ref in resourceRefs where !contentJSON.contains(ref) { - ensureImageInContent(imageSrc: ref, alt: "image") - } - markDirtyAndScheduleSave() }, onResourcePress: { target in resourceTarget = target @@ -437,7 +433,7 @@ struct MemoEditView: View { dismiss() } else { await flushPending() - dismiss() + leaveEditor() } } @@ -447,6 +443,16 @@ struct MemoEditView: View { await commitCreate() } else { await flushPending() + leaveEditor() + } + } + + /// Edit-from-detail: parent pops path then clears the cover so list is revealed. + /// Create / other hosts: standard environment dismiss. + private func leaveEditor() { + if let onLeaveToList { + onLeaveToList() + } else { dismiss() } } @@ -460,13 +466,51 @@ struct MemoEditView: View { /// Pull markdown/JSON from TipTap so saves don't race the async change bridge. private func pullEditorSnapshotIfPossible() async { guard let snap = await SharedTipTapRuntime.editor.snapshotContent() else { return } - if !snap.json.isEmpty { - contentJSON = snap.json - } - // Prefer snapshot markdown, but keep any image refs we already patched in. - if !snap.markdown.isEmpty { - contentMarkdown = snap.markdown + applyEditorPayload(markdown: snap.markdown, json: snap.json) + } + + /// Merge TipTap bridge payloads. + /// **TipTap JSON is the only structural source of truth** (node order = image above/below text). + /// Markdown is always derived from JSON so we never "append missing images at the end" + /// and flip 图文顺序 when the serializer drops an image mid-document. + private func applyEditorPayload(markdown: String, json: String) { + let prevText = EditorContentCodec.plainText(markdown: contentMarkdown, json: contentJSON) + + let trimmedJSON = json.trimmingCharacters(in: .whitespacesAndNewlines) + let nextJSON: String = { + if !trimmedJSON.isEmpty, EditorContentCodec.looksLikeTipTapDoc(trimmedJSON) { + return trimmedJSON + } + return contentJSON + }() + + // Derive markdown from JSON whenever possible — preserves block order exactly. + let nextMD: String = { + if let derived = EditorContentCodec.markdownFromTipTapJSON(nextJSON) { + return derived + } + if !markdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return markdown + } + return contentMarkdown + }() + + let nextText = EditorContentCodec.plainText(markdown: nextMD, json: nextJSON) + let nextHasImage = EditorContentCodec.containsImageNode(nextJSON) + || nextMD.contains("![") + + // Hard guard: never accept a payload that strips substantial text while leaving media. + if prevText.count >= 8, nextText.count < max(4, prevText.count / 2), nextHasImage { + NSLog( + "MemoEditView: reject text-stripping payload prevText=%d nextText=%d", + prevText.count, nextText.count + ) + // Do NOT append images at the end — that reorders 图文. Keep prior body. + return } + + contentJSON = nextJSON + contentMarkdown = nextMD } private func loadInitial() async { @@ -554,21 +598,36 @@ struct MemoEditView: View { } } - /// Reject autosave that would wipe a non-empty note with an empty editor boot payload. + /// Reject autosave that would wipe a non-empty note with an empty / image-only boot payload. private var wouldClobberNonEmptyBody: Bool { guard !isCreate else { return false } let next = contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines) let base = baselineMarkdown.trimmingCharacters(in: .whitespacesAndNewlines) - return next.isEmpty && !base.isEmpty + if next.isEmpty && !base.isEmpty { return true } + + let baseText = EditorContentCodec.plainTextFromMarkdown(baselineMarkdown) + let nextText = EditorContentCodec.plainText(markdown: contentMarkdown, json: contentJSON) + let nextHasImage = contentMarkdown.contains("/api/v1/resources/") + || contentJSON.contains("/api/v1/resources/") + || contentJSON.contains("\"type\":\"image\"") + // Image-only body while baseline had real text → refuse (this produced empty notes). + if baseText.count >= 8, nextText.count < max(4, baseText.count / 2), nextHasImage { + return true + } + return false } private func persistDraftOrQueue() async { guard let scope = env.session.dataScope else { return } guard contentHydrated else { return } + // Last chance: re-sync markdown from JSON so outbox markdown cannot drop text. + reconcileMarkdownWithJSON() if wouldClobberNonEmptyBody { - #if DEBUG - NSLog("MemoEditView: skip persist — refusing empty clobber of non-empty baseline") - #endif + NSLog( + "MemoEditView: skip persist — refusing body clobber baseText=%d nextText=%d", + EditorContentCodec.plainTextFromMarkdown(baselineMarkdown).count, + EditorContentCodec.plainText(markdown: contentMarkdown, json: contentJSON).count + ) isDirty = false return } @@ -637,10 +696,13 @@ struct MemoEditView: View { if let json = try? JSONValue.parse(contentJSON) { memo.contentJson = json } + // Capture server base BEFORE writing local content. Never prefer a stale + // in-memory expectedRevision that lagged behind a completed sync — that was + // causing endless "Note changed before the offline draft could sync" conflicts. + let rev = memo.revision + let hash = memo.contentHash try? env.mirror.upsertMemo(scope: scope, memo: memo) - let rev = expectedRevision ?? memo.revision - let hash = expectedContentHash ?? memo.contentHash try? env.outbox.enqueueUpdate( scope: scope, payload: MemoUpdatePayload( @@ -653,6 +715,8 @@ struct MemoEditView: View { tags: tags ) ) + expectedRevision = rev + expectedContentHash = hash try? env.drafts.write( scope: scope, draft: MemoDraft( @@ -667,13 +731,19 @@ struct MemoEditView: View { ) ) NSLog( - "MemoEditView persist update memo=%@ mdLen=%d hasImg=%d jsonHasImg=%d", + "MemoEditView persist update memo=%@ baseRev=%d mdLen=%d hasImg=%d jsonHasImg=%d", memo.id, + rev, contentMarkdown.count, contentMarkdown.contains("/api/v1/resources/") ? 1 : 0, contentJSON.contains("/api/v1/resources/") ? 1 : 0 ) - Task { await env.runSyncCycle() } + await env.runSyncCycle() + // Refresh base from mirror after flush so the next keystroke doesn't reuse a dead revision. + if let refreshed = try? env.mirror.resolveMemo(scope: scope, id: memoId) { + expectedRevision = refreshed.revision + expectedContentHash = refreshed.contentHash + } } private func commitCreate() async { @@ -705,6 +775,11 @@ struct MemoEditView: View { memoId = id } await env.runSyncCycle() + if let id = memoId, let refreshed = try? env.mirror.resolveMemo(scope: scope, id: id) { + expectedRevision = refreshed.revision + expectedContentHash = refreshed.contentHash + } + // Create modal sits on the list already. dismiss() } catch { self.error = error.localizedDescription @@ -747,6 +822,9 @@ struct MemoEditView: View { try env.mirror.deleteMemo(scope: scope, id: localId) } + // Capture latest editor body before minting the server memo (avoid stale empty markdown). + await pullEditorSnapshotIfPossible() + reconcileMarkdownWithJSON() let memo = try await env.session.client.createMemo( notebookId: notebookId.isEmpty ? (availableNotebooks.first?.id ?? "") : notebookId, title: title.isEmpty ? "无标题笔记" : title, @@ -816,20 +894,24 @@ struct MemoEditView: View { ) ) } - // Snapshot TipTap state immediately — do not rely on async bridge onChange - // (it can arrive after our save, or markdown serializers may drop images). + // Snapshot TipTap JSON (order is authoritative). Only inject if the resource + // is truly missing — never append a second image node at document end. await pullEditorSnapshotIfPossible() - ensureImageInContent(imageSrc: imageSrc, alt: prepared.filename) + if !EditorContentCodec.jsonContainsResource(contentJSON, src: imageSrc) { + ensureImageInContent(imageSrc: imageSrc, alt: prepared.filename) + } else { + reconcileMarkdownWithJSON() + } isDirty = true // Persist immediately so leaving the editor cannot orphan the resource. saveTask?.cancel() await persistDraftOrQueue() - await env.runSyncCycle() NSLog( - "MemoEditView insertImageData: done src=%@ mdHas=%d jsonHas=%d", + "MemoEditView insertImageData: done src=%@ mdHas=%d jsonHas=%d textLen=%d", imageSrc, contentMarkdown.contains(imageSrc) ? 1 : 0, - contentJSON.contains(imageSrc) ? 1 : 0 + EditorContentCodec.jsonContainsResource(contentJSON, src: imageSrc) ? 1 : 0, + EditorContentCodec.plainText(markdown: contentMarkdown, json: contentJSON).count ) } catch { self.error = error.localizedDescription @@ -838,58 +920,25 @@ struct MemoEditView: View { } } - private static func protectedResourceRefs(in markdown: String) -> [String] { - guard let regex = try? NSRegularExpression( - pattern: #"/api/v1/resources/[A-Za-z0-9_-]+(?:/blob)?"#, - options: [] - ) else { return [] } - let ns = markdown as NSString - let matches = regex.matches(in: markdown, options: [], range: NSRange(location: 0, length: ns.length)) - var seen = Set() - var refs: [String] = [] - for match in matches { - let ref = ns.substring(with: match.range) - if seen.insert(ref).inserted { - refs.append(ref) - } - } - return refs + private static func protectedResourceRefs(in text: String) -> [String] { + EditorContentCodec.protectedResourceRefs(in: text) } - /// Guarantee markdown + TipTap JSON both reference the uploaded resource path. - private func ensureImageInContent(imageSrc: String, alt: String) { - if !contentMarkdown.contains(imageSrc) { - let block = contentMarkdown.hasSuffix("\n") || contentMarkdown.isEmpty - ? "![\(alt)](\(imageSrc))\n" - : "\n\n![\(alt)](\(imageSrc))\n" - contentMarkdown += block + /// Rebuild markdown from TipTap JSON so block order matches the editor (and detail view). + private func reconcileMarkdownWithJSON() { + if let derived = EditorContentCodec.markdownFromTipTapJSON(contentJSON) { + contentMarkdown = derived } - if contentJSON.contains(imageSrc) { return } - // Inject an image node into the JSON doc when serializers/snapshot omitted it. - guard let data = contentJSON.data(using: .utf8), - var doc = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { - contentJSON = """ - {"type":"doc","content":[{"type":"paragraph"},{"type":"image","attrs":{"src":"\(imageSrc)","alt":"\(alt.replacingOccurrences(of: "\"", with: ""))"}},{"type":"paragraph"}]} - """ + } + + /// Last-resort: image missing from JSON after insert. Append only then, and rebuild md from JSON. + private func ensureImageInContent(imageSrc: String, alt: String) { + if EditorContentCodec.jsonContainsResource(contentJSON, src: imageSrc) { + reconcileMarkdownWithJSON() return } - var content = doc["content"] as? [[String: Any]] ?? [] - content.append([ - "type": "image", - "attrs": [ - "src": imageSrc, - "alt": alt, - ] as [String: Any], - ]) - content.append(["type": "paragraph"] as [String: Any]) - doc["type"] = doc["type"] ?? "doc" - doc["content"] = content - if let out = try? JSONSerialization.data(withJSONObject: doc), - let s = String(data: out, encoding: .utf8) - { - contentJSON = s - } + contentJSON = EditorContentCodec.appendingImage(toJSON: contentJSON, src: imageSrc, alt: alt) + reconcileMarkdownWithJSON() } /// When compression is off, still normalize HEIC → JPEG for reliable upload mime. @@ -913,6 +962,211 @@ struct MemoEditView: View { } } +// MARK: - TipTap content helpers (markdown serializer is lossy around images) + +enum EditorContentCodec { + static func protectedResourceRefs(in text: String) -> [String] { + guard let regex = try? NSRegularExpression( + pattern: #"/api/v1/resources/[A-Za-z0-9_-]+(?:/blob)?"#, + options: [] + ) else { return [] } + let ns = text as NSString + let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length)) + var seen = Set() + var refs: [String] = [] + for match in matches { + let ref = ns.substring(with: match.range) + if seen.insert(ref).inserted { refs.append(ref) } + } + return refs + } + + static func looksLikeTipTapDoc(_ json: String) -> Bool { + guard let data = json.data(using: .utf8), + let doc = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = doc["type"] as? String + else { return false } + return type == "doc" + } + + static func containsImageNode(_ json: String) -> Bool { + json.contains("\"type\":\"image\"") || json.contains("\"type\": \"image\"") + } + + /// Match resource by id so `/blob` vs bare path doesn't look like a missing image. + static func jsonContainsResource(_ json: String, src: String) -> Bool { + if json.contains(src) { return true } + if let id = ResourceCache.resourceId(from: src), json.contains(id) { + return true + } + return false + } + + static func plainText(markdown: String, json: String) -> String { + let a = plainTextFromMarkdown(markdown) + let b = plainTextFromJSON(json) + return a.count >= b.count ? a : b + } + + static func plainTextFromMarkdown(_ markdown: String) -> String { + var s = markdown + // Strip image / link targets; keep link labels lightly. + s = s.replacingOccurrences(of: #"!\[[^\]]*\]\([^)]*\)"#, with: " ", options: .regularExpression) + s = s.replacingOccurrences(of: #"\[[^\]]*\]\([^)]*\)"#, with: " ", options: .regularExpression) + s = s.replacingOccurrences(of: #"[#>*`_~\-]+"#, with: " ", options: .regularExpression) + return s + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + static func plainTextFromJSON(_ json: String) -> String { + guard let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) + else { return "" } + var parts: [String] = [] + collectText(obj, into: &parts) + return parts.joined(separator: " ") + } + + private static func collectText(_ any: Any, into parts: inout [String]) { + if let dict = any as? [String: Any] { + if let type = dict["type"] as? String, type == "text", let text = dict["text"] as? String, !text.isEmpty { + parts.append(text) + } + if let content = dict["content"] as? [Any] { + for child in content { collectText(child, into: &parts) } + } + return + } + if let arr = any as? [Any] { + for child in arr { collectText(child, into: &parts) } + } + } + + /// Best-effort TipTap JSON → markdown so sync wire format keeps body text + images. + static func markdownFromTipTapJSON(_ json: String) -> String? { + guard let data = json.data(using: .utf8), + let doc = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let content = doc["content"] as? [Any] + else { return nil } + var lines: [String] = [] + for node in content { + guard let dict = node as? [String: Any], let type = dict["type"] as? String else { continue } + switch type { + case "paragraph": + lines.append(inlineMarkdown(dict["content"] as? [Any] ?? [])) + case "heading": + let level = (dict["attrs"] as? [String: Any])?["level"] as? Int ?? 2 + let prefix = String(repeating: "#", count: min(max(level, 1), 6)) + lines.append("\(prefix) \(inlineMarkdown(dict["content"] as? [Any] ?? []))") + case "image": + let attrs = dict["attrs"] as? [String: Any] ?? [:] + let src = attrs["src"] as? String ?? "" + let alt = attrs["alt"] as? String ?? "" + if !src.isEmpty { lines.append("![\(alt)](\(src))") } + case "bulletList", "orderedList": + if let items = dict["content"] as? [Any] { + for item in items { + guard let itemDict = item as? [String: Any] else { continue } + let text = blockText(itemDict) + if !text.isEmpty { lines.append("- \(text)") } + } + } + case "blockquote": + let text = blockText(dict) + if !text.isEmpty { lines.append("> \(text)") } + case "codeBlock": + let text = blockText(dict) + lines.append("```\n\(text)\n```") + case "horizontalRule": + lines.append("---") + default: + let text = blockText(dict) + if !text.isEmpty { lines.append(text) } + } + } + let joined = lines.joined(separator: "\n\n").trimmingCharacters(in: .whitespacesAndNewlines) + return joined.isEmpty ? nil : joined + "\n" + } + + private static func blockText(_ node: [String: Any]) -> String { + if let type = node["type"] as? String, type == "text" { + return node["text"] as? String ?? "" + } + guard let content = node["content"] as? [Any] else { return "" } + return content.compactMap { child -> String? in + guard let dict = child as? [String: Any] else { return nil } + if dict["type"] as? String == "image" { + let attrs = dict["attrs"] as? [String: Any] ?? [:] + let src = attrs["src"] as? String ?? "" + let alt = attrs["alt"] as? String ?? "" + return src.isEmpty ? nil : "![\(alt)](\(src))" + } + let t = blockText(dict) + return t.isEmpty ? nil : t + }.joined() + } + + private static func inlineMarkdown(_ content: [Any]) -> String { + content.compactMap { child -> String? in + guard let dict = child as? [String: Any] else { return nil } + if dict["type"] as? String == "hardBreak" { return "\n" } + if dict["type"] as? String == "text" { + var t = dict["text"] as? String ?? "" + let marks = dict["marks"] as? [[String: Any]] ?? [] + // Apply outer marks last so **`code`** style nesting is reasonable. + for mark in marks.reversed() { + switch mark["type"] as? String { + case "code": + t = "`\(t)`" + case "bold", "strong": + t = "**\(t)**" + case "italic", "em": + t = "*\(t)*" + case "strike": + t = "~~\(t)~~" + case "link": + let href = (mark["attrs"] as? [String: Any])?["href"] as? String ?? "" + t = "[\(t)](\(href))" + default: + break + } + } + return t + } + return blockText(dict) + }.joined() + } + + /// Append image node without wiping existing document content. + static func appendingImage(toJSON json: String, src: String, alt: String) -> String { + if json.contains(src) { return json } + guard let data = json.data(using: .utf8), + var doc = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + // Only invent a stub when we truly have no document yet. + let safeAlt = alt.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + return """ + {"type":"doc","content":[{"type":"paragraph"},{"type":"image","attrs":{"src":"\(src)","alt":"\(safeAlt)"}},{"type":"paragraph"}]} + """ + } + var content = doc["content"] as? [[String: Any]] ?? [] + // Avoid blanking a doc that already has text — only append. + content.append([ + "type": "image", + "attrs": ["src": src, "alt": alt] as [String: Any], + ]) + content.append(["type": "paragraph"] as [String: Any]) + doc["type"] = doc["type"] ?? "doc" + doc["content"] = content + guard let out = try? JSONSerialization.data(withJSONObject: doc), + let s = String(data: out, encoding: .utf8) + else { return json } + return s + } +} + // MARK: - System PHPicker (reliable; PhotosPicker+Transferable was a silent no-op) private enum ImagePickerResult { diff --git a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift index c3e5ab71..ec13eb44 100644 --- a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift @@ -53,7 +53,17 @@ struct WorkspaceView: View { .navigationBarHidden(true) .navigationDestination(for: String.self) { memoId in MemoDetailView(memoId: memoId) { editId in + // 1) Show edit cover over detail. editingMemo = EditingMemoRoute(id: editId) + // 2) After the cover is up, silently drop detail so the underlay is the list. + // Dismissing edit then reveals list only — no detail flash. + DispatchQueue.main.async { + var t = Transaction() + t.disablesAnimations = true + withTransaction(t) { + path = NavigationPath() + } + } } } // Android Me is full-screen (activeView === "settings"), not a half-sheet Form. @@ -65,12 +75,22 @@ struct WorkspaceView: View { MemoEditView(mode: .create(notebookId: store.selectedNotebookId ?? store.notebooks.first?.id ?? "")) .onDisappear { store.reload(env: env) } } - // Edit from detail FAB — present at root so NavigationStack destination covers always work. + // Edit cover on workspace root; underlay is list (detail popped once cover is presented). .fullScreenCover(item: $editingMemo) { route in - MemoEditView(mode: .edit(memoId: route.id)) - .onDisappear { + MemoEditView( + mode: .edit(memoId: route.id), + onLeaveToList: { + // Pop detail first (no animation) while cover still covers the stack, + // then dismiss the cover so the user only ever sees the list. + var t = Transaction() + t.disablesAnimations = true + withTransaction(t) { + path = NavigationPath() + } + editingMemo = nil store.reload(env: env) } + ) } .sheet(isPresented: $store.showNotebookPicker) { NotebookPickerSheet(store: store) diff --git a/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift b/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift index b26073d7..de9a666d 100644 --- a/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift +++ b/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift @@ -34,18 +34,27 @@ final class EditorBundleTests: XCTestCase { .deletingLastPathComponent() // EdgeEverTests .deletingLastPathComponent() // Tests .deletingLastPathComponent() // ios - let tipTap = try String( + let webView = try String( contentsOf: iosRoot.appendingPathComponent("EdgeEver/Editor/TipTapWebView.swift"), encoding: .utf8 ) - // Must not invoke focusEnd after setMarkdown / setDocumentFromJSON. + let runtime = try String( + contentsOf: iosRoot.appendingPathComponent("EdgeEver/Editor/TipTapWarmPool.swift"), + encoding: .utf8 + ) + // SwiftUI surface must not force caret; runtime owns open-edit focus once. XCTAssertFalse( - tipTap.contains("focusEnd()"), + webView.contains("focusEnd()"), "TipTapWebView must not call focusEnd() — jumps caret to bottom while editing" ) XCTAssertTrue( - tipTap.contains("lastEditorEmittedFingerprint"), + runtime.contains("lastEditorEmittedFingerprint"), "must ignore editor-originated updates so typing does not re-setContent" ) + // Viewer detail must prefer Markdown (setMarkdown), not flattened contentJson. + XCTAssertTrue( + runtime.contains("TipTapContentSource.resolve") || runtime.contains("contentDecision"), + "runtime must route content through TipTapContentSource policy" + ) } } diff --git a/apps/ios/Tests/EdgeEverTests/TipTapContentSourceTests.swift b/apps/ios/Tests/EdgeEverTests/TipTapContentSourceTests.swift new file mode 100644 index 00000000..e82668bb --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/TipTapContentSourceTests.swift @@ -0,0 +1,192 @@ +import WebKit +import XCTest +@testable import EdgeEver + +final class TipTapContentSourceTests: XCTestCase { + /// Flattened JSON + rich markdown must NOT drive the detail viewer (regression). + func testViewerAlwaysPrefersMarkdownWhenPresent() { + let richMD = """ + ## Hello + + This is **bold** and a list: + + - one + - two + + ```swift + let x = 1 + ``` + """ + let flatJSON = """ + {"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"## Hello This is **bold**"}]}]} + """ + let d = TipTapContentSource.resolve( + mode: .viewer, + documentJSON: flatJSON, + markdown: richMD + ) + XCTAssertFalse(d.useJSON, "viewer must setMarkdown, not flattened JSON") + XCTAssertEqual(d.payload, richMD) + XCTAssertTrue(d.fingerprint.hasPrefix("md:")) + } + + func testViewerFallsBackToJSONOnlyWhenMarkdownEmpty() { + let json = """ + {"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hi"}]}]} + """ + let d = TipTapContentSource.resolve(mode: .viewer, documentJSON: json, markdown: " \n") + XCTAssertTrue(d.useJSON) + XCTAssertTrue(d.fingerprint.hasPrefix("json:")) + } + + func testEditorPrefersMarkdownWhenStructurallyRicher() { + let md = "# Title\n\n- a\n- b\n\n```\ncode\n```\n" + let flat = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Title a b code\"}]}]}" + let d = TipTapContentSource.resolve(mode: .editor, documentJSON: flat, markdown: md) + XCTAssertFalse(d.useJSON) + } + + func testEditorPrefersJSONForSimpleImageNotes() { + let md = "hello\n\n![](/api/v1/resources/res_x/blob)\n" + let json = """ + {"type":"doc","content":[ + {"type":"paragraph","content":[{"type":"text","text":"hello"}]}, + {"type":"image","attrs":{"src":"/api/v1/resources/res_x/blob","alt":""}} + ]} + """ + let d = TipTapContentSource.resolve(mode: .editor, documentJSON: json, markdown: md) + // Image node in JSON scores structure; simple md shouldn't force markdown path. + XCTAssertTrue(d.useJSON, "simple image+text notes should keep JSON for order fidelity") + } + + /// Live WKWebView: packaged TipTap must turn Markdown into real DOM structure. + @MainActor + func testPackagedEditorSetMarkdownRendersHeadingsListsAndBold() async throws { + guard let htmlURL = TipTapResourceLoader.packagedEditorHTMLURL() else { + // Host app test target should embed EditorBundle; fail loudly if not. + let alt = Bundle(for: TipTapContentSourceTests.self).url( + forResource: "index", + withExtension: "html", + subdirectory: "EditorBundle" + ) + XCTAssertNotNil(alt, "EditorBundle missing from test host") + throw XCTSkip("EditorBundle not in test host bundle — run against EdgeEver app target tests") + } + + let config = WKWebViewConfiguration() + config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 800), configuration: config) + let dir = htmlURL.deletingLastPathComponent() + webView.loadFileURL(htmlURL, allowingReadAccessTo: dir) + + // Wait for EdgeEverEditor bridge. + let ready = expectation(description: "editor ready") + var attempts = 0 + func pollReady() { + webView.evaluateJavaScript("!!window.EdgeEverEditor") { value, _ in + if (value as? Bool) == true { + ready.fulfill() + } else { + attempts += 1 + if attempts > 80 { + ready.fulfill() // let assert fail below + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: pollReady) + } + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: pollReady) + await fulfillment(of: [ready], timeout: 12) + + let hasEditor = try await evalBool(webView, "!!window.EdgeEverEditor") + XCTAssertTrue(hasEditor, "EdgeEverEditor bridge must load") + + // Configure viewer and push markdown (same path detail page uses). + _ = try await eval(webView, """ + window.EdgeEverEditor.configure({ mode: 'viewer', locale: 'zh-CN', theme: 'light' }); + true + """) + + let sample = """ + ## 标题渲染 + + 这是 **粗体** 文本。 + + - 列表一项 + - 列表二项 + + ```js + console.log(1) + ``` + """ + let b64 = Data(sample.utf8).base64EncodedString() + _ = try await eval(webView, """ + (function(){ + var bin = atob('\(b64)'); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + var md = new TextDecoder('utf-8').decode(bytes); + window.EdgeEverEditor.setMarkdown(md); + return true; + })() + """) + + // Give setContent + afterContentSet a beat. + try await Task.sleep(nanoseconds: 400_000_000) + + let h2 = try await evalInt(webView, "document.querySelectorAll('h1,h2,h3').length") + let strong = try await evalInt(webView, "document.querySelectorAll('strong,b').length") + let li = try await evalInt(webView, "document.querySelectorAll('li').length") + let pre = try await evalInt(webView, "document.querySelectorAll('pre').length") + let rawLeak = try await evalBool( + webView, + "document.body.innerText.indexOf('## 标题渲染') >= 0 && document.querySelectorAll('h1,h2,h3').length === 0" + ) + + XCTAssertGreaterThanOrEqual(h2, 1, "heading must render as h1/h2/h3, not plain text") + XCTAssertGreaterThanOrEqual(strong, 1, "bold markdown must render as strong/b") + XCTAssertGreaterThanOrEqual(li, 2, "list items must render") + XCTAssertGreaterThanOrEqual(pre, 1, "fenced code must render as pre") + XCTAssertFalse(rawLeak, "must not show raw '## heading' as plain text without heading nodes") + + // Contrast: flattened JSON path must NOT be what viewer policy selects. + let decision = TipTapContentSource.resolve( + mode: .viewer, + documentJSON: """ + {"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"## 标题渲染 这是 **粗体**"}]}]} + """, + markdown: sample + ) + XCTAssertFalse(decision.useJSON) + } + + // MARK: - JS helpers + + @MainActor + private func eval(_ webView: WKWebView, _ js: String) async throws -> Any? { + try await withCheckedThrowingContinuation { cont in + webView.evaluateJavaScript(js) { value, error in + if let error { cont.resume(throwing: error) } + else { cont.resume(returning: value) } + } + } + } + + @MainActor + private func evalBool(_ webView: WKWebView, _ js: String) async throws -> Bool { + let v = try await eval(webView, js) + if let b = v as? Bool { return b } + if let n = v as? NSNumber { return n.boolValue } + return false + } + + @MainActor + private func evalInt(_ webView: WKWebView, _ js: String) async throws -> Int { + let v = try await eval(webView, js) + if let i = v as? Int { return i } + if let n = v as? NSNumber { return n.intValue } + if let d = v as? Double { return Int(d) } + return 0 + } +}