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 + } +}