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.
This commit is contained in:
tianma-if committed 2026-08-07 12:19:14 +08:00
1 parent 16c1d836d5
commit aaa8d47799
10 files changed
+718 -123

No files matched your search

@@ -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 = "<group>"; };
86F590FE9371571DC7456154 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
8720E0D6F00D1509B76A374B /* OutboxFlusher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxFlusher.swift; sourceTree = "<group>"; };
8A536119D29812EF11DAEAA8 /* TipTapContentSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipTapContentSourceTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
9B02E8D28111E1E732F3BF36 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
@@ -140,6 +143,7 @@
A375D81573943A98C5732F81 /* MobileUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileUI.swift; sourceTree = "<group>"; };
AB02424D0412FE36C4BCD116 /* index.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = index.html; sourceTree = "<group>"; };
AD904708FC1AD8971439BD90 /* JSONCoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoding.swift; sourceTree = "<group>"; };
AE1D4EB4B51C66E9CD72FF7F /* TipTapContentSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipTapContentSource.swift; sourceTree = "<group>"; };
B1D94BB61519CA2A345D99C1 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
B30C95F4EABCD08555D97C6B /* NotesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesListView.swift; sourceTree = "<group>"; };
B812AD3502D649CE7B84DEBE /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
@@ -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 */,
@@ -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
""",
+56 -17
View File
@@ -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
)
}
}
@@ -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
}
}
+15 -12
View File
@@ -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
@@ -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(
@@ -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<String>()
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<String>()
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 {
@@ -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)
@@ -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"
)
}
}
@@ -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
}
}