refactor: harden mobile editing and PWA caching

This commit is contained in:
tianma-if committed 2026-08-08 21:47:55 +08:00
1 parent c9f2c0f6a2
commit 30f47dac4c
11 files changed
+742 -589

No files matched your search

@@ -31,6 +31,7 @@ struct MemoEditView: View {
@State private var memoId: String?
@State private var error: String?
@State private var saveTask: Task<Void, Never>?
@State private var editGeneration: UInt64 = 0
@State private var isMaterializing = false
@State private var isDirty = false
@State private var isSaving = false
@@ -352,8 +353,9 @@ struct MemoEditView: View {
token: env.session.session?.token,
onChange: { md, json in
guard contentHydrated, !suppressPersistence else { return }
// Always accept editor truth, even mid-upload (save stays gated).
// @tiptap/markdown can drop text or images — reconcile carefully.
// Accept the JSON and Markdown emitted by the same TipTap transaction.
// Native code only falls back to its compatibility serializer when the
// editor cannot provide Markdown at all.
applyEditorPayload(markdown: md, json: json)
if !isUploading {
markDirtyAndScheduleSave()
@@ -469,6 +471,7 @@ struct MemoEditView: View {
private func markDirtyAndScheduleSave() {
guard !suppressPersistence else { return }
editGeneration &+= 1
// Avoid autosaving mid-upload (placeholder / incomplete body).
guard !isUploading else {
isDirty = true
@@ -496,8 +499,7 @@ struct MemoEditView: View {
contentJSON = emptyDocJSON
baselineMarkdown = seed.contentMarkdown
editorReady = false
isDirty = true
scheduleSave()
markDirtyAndScheduleSave()
// Re-push body into the shared editor runtime.
Task { @MainActor in
try? await Task.sleep(nanoseconds: 50_000_000)
@@ -567,16 +569,11 @@ struct MemoEditView: View {
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 nextMD = EditorContentCodec.preferredMarkdown(
editorMarkdown: markdown,
documentJSON: nextJSON,
fallback: contentMarkdown
)
let nextText = EditorContentCodec.plainText(markdown: nextMD, json: nextJSON)
let nextHasImage = EditorContentCodec.containsImageNode(nextJSON)
@@ -710,7 +707,10 @@ struct MemoEditView: View {
private func persistDraftOrQueue() async {
guard let scope = env.session.dataScope else { return }
guard contentHydrated, !suppressPersistence else { return }
// Last chance: re-sync markdown from JSON so outbox markdown cannot drop text.
guard !isSaving else { return }
let generationAtStart = editGeneration
// Last chance: only backfill Markdown when the editor supplied none. Never replace
// valid TipTap Markdown with the intentionally limited native compatibility serializer.
reconcileMarkdownWithJSON()
if wouldClobberNonEmptyBody {
NSLog(
@@ -724,7 +724,11 @@ struct MemoEditView: View {
isSaving = true
defer {
isSaving = false
isDirty = false
if generationAtStart == editGeneration {
isDirty = false
} else if !suppressPersistence {
scheduleSave()
}
}
let now = EdgeEverDate.nowString()
@@ -898,10 +902,25 @@ struct MemoEditView: View {
}
private func flushPending() async {
await persistDraftOrQueue()
await drainPendingSave()
await env.runSyncCycle()
}
/// Wait for an in-flight autosave before forcing the latest editor generation to disk.
private func drainPendingSave() async {
let pendingSave = saveTask
saveTask = nil
pendingSave?.cancel()
await pendingSave?.value
// An older save may have noticed a newer generation and scheduled another debounce.
// Cancel that debounce because this path persists the latest state immediately.
saveTask?.cancel()
saveTask = nil
if isDirty {
await persistDraftOrQueue()
}
}
/// K24 materialize: ensure a server memo id before image upload.
private func materializeForImage() async throws -> String {
if let memoId, !memoId.hasPrefix("local:") {
@@ -1015,10 +1034,10 @@ struct MemoEditView: View {
} else {
reconcileMarkdownWithJSON()
}
editGeneration &+= 1
isDirty = true
// Persist immediately so leaving the editor cannot orphan the resource.
saveTask?.cancel()
await persistDraftOrQueue()
await drainPendingSave()
NSLog(
"MemoEditView insertImageData: done src=%@ mdHas=%d jsonHas=%d textLen=%d",
imageSrc,
@@ -1037,21 +1056,25 @@ struct MemoEditView: View {
EditorContentCodec.protectedResourceRefs(in: text)
}
/// Rebuild markdown from TipTap JSON so block order matches the editor (and detail view).
/// Compatibility fallback for legacy/editor failures that provide JSON without Markdown.
private func reconcileMarkdownWithJSON() {
if let derived = EditorContentCodec.markdownFromTipTapJSON(contentJSON) {
contentMarkdown = derived
}
guard contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let recovered = EditorContentCodec.markdownFromTipTapJSON(contentJSON)
else { return }
contentMarkdown = recovered
}
/// Last-resort: image missing from JSON after insert. Append only then, and rebuild md from JSON.
/// Last-resort: image missing from JSON after insert. Append it to both representations.
private func ensureImageInContent(imageSrc: String, alt: String) {
if EditorContentCodec.jsonContainsResource(contentJSON, src: imageSrc) {
reconcileMarkdownWithJSON()
return
}
contentJSON = EditorContentCodec.appendingImage(toJSON: contentJSON, src: imageSrc, alt: alt)
reconcileMarkdownWithJSON()
if !contentMarkdown.contains(imageSrc) {
let separator = contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "" : "\n\n"
contentMarkdown += "\(separator)![\(alt)](\(imageSrc))\n"
}
}
/// When compression is off, still normalize HEIC → JPEG for reliable upload mime.
@@ -1075,9 +1098,27 @@ struct MemoEditView: View {
}
}
// MARK: - TipTap content helpers (markdown serializer is lossy around images)
// MARK: - TipTap content helpers (native Markdown serializer is recovery-only)
enum EditorContentCodec {
/// TipTap's Markdown extension is authoritative because it understands the complete
/// registered schema (tables, nested/ordered lists, code languages, links and images).
/// The native serializer remains only as a recovery path for an absent bridge payload.
static func preferredMarkdown(
editorMarkdown: String?,
documentJSON: String,
fallback: String
) -> String {
// A present-but-empty payload means the user intentionally cleared the note.
if let editorMarkdown {
return editorMarkdown
}
if !fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return fallback
}
return markdownFromTipTapJSON(documentJSON) ?? ""
}
static func protectedResourceRefs(in text: String) -> [String] {
guard let regex = try? NSRegularExpression(
pattern: #"/api/v1/resources/[A-Za-z0-9_-]+(?:/blob)?"#,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+26
View File
@@ -10,6 +10,10 @@ import { NodeSelection } from "@tiptap/pm/state";
import mermaid from "mermaid";
/** Keep in sync with packages/shared MergeDivider (iOS bundle cannot import monorepo shared). */
const MERGE_DIVIDER_MARKDOWN_MARKER = "<!-- edgeever:merge-divider -->";
const MERGE_DIVIDER_TOKENIZER =
/^<!--\s*edgeever:merge-divider\s*-->\s*(?:\n+---[ \t]*(?:\n+|$)|(?:\n+|$))/;
const MergeDivider = Node.create({
name: "edgeeverMergeDivider",
group: "block",
@@ -28,6 +32,28 @@ const MergeDivider = Node.create({
}),
];
},
renderMarkdown() {
return `${MERGE_DIVIDER_MARKDOWN_MARKER}\n\n---`;
},
parseMarkdown(_token, helpers) {
return helpers.createNode("edgeeverMergeDivider");
},
markdownTokenizer: {
name: "edgeeverMergeDivider",
level: "block",
start(source: string) {
return source.indexOf(MERGE_DIVIDER_MARKDOWN_MARKER);
},
tokenize(source: string) {
const match = MERGE_DIVIDER_TOKENIZER.exec(source);
if (!match) return undefined;
return {
type: "edgeeverMergeDivider",
raw: match[0],
text: "",
};
},
},
});
type BridgeMessage =
@@ -151,7 +151,10 @@ final class ChromeParityTests: XCTestCase {
func testWorkspacePresentsCreateAsFullScreenCover() throws {
let src = try readShippedSource("Features/Workspace/WorkspaceView.swift")
XCTAssertTrue(src.contains("fullScreenCover(isPresented: $showNewNote)"), src)
XCTAssertTrue(src.contains("MemoEditView(mode: .create"), "create wiring")
XCTAssertTrue(
src.contains("mode: .create(") || src.contains("MemoEditView(mode: .create"),
"create wiring"
)
XCTAssertTrue(src.contains("fullScreenCover(isPresented: $showSettings)"), "Me full screen")
}
@@ -187,8 +187,16 @@ final class MobileUIParityTests: XCTestCase {
token: nil,
resourceCache: cache
)
XCTAssertTrue(out.contains("data:image/svg+xml"), out)
XCTAssertFalse(out.contains("/api/v1/resources/\(testId)/blob"), "protected path must be rewritten before setContent")
let data = try XCTUnwrap(out.data(using: .utf8))
let document = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
let content = try XCTUnwrap(document["content"] as? [[String: Any]])
let attrs = try XCTUnwrap(content.first?["attrs"] as? [String: Any])
let hydratedSource = try XCTUnwrap(attrs["src"] as? String)
XCTAssertTrue(hydratedSource.hasPrefix("data:image/svg+xml"), hydratedSource)
XCTAssertFalse(
hydratedSource.contains("/api/v1/resources/\(testId)/blob"),
"protected path must be rewritten before setContent"
)
let stale = await cache.fileURL(for: testId)
try? FileManager.default.removeItem(at: stale)
}
@@ -3,6 +3,56 @@ import XCTest
@testable import EdgeEver
final class TipTapContentSourceTests: XCTestCase {
func testEditorMarkdownRemainsAuthoritativeForRichStructures() {
let markdown = """
1. first
1. nested
```swift
let value = 1
```
"""
let json = """
{"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]},{"type":"codeBlock","attrs":{"language":"swift"},"content":[{"type":"text","text":"let value = 1"}]}]}
"""
XCTAssertEqual(
EditorContentCodec.preferredMarkdown(
editorMarkdown: markdown,
documentJSON: json,
fallback: ""
),
markdown
)
}
func testEditorMarkdownFallbackDoesNotReplaceExistingCompatibilityCopy() {
let fallback = "| A | B |\n| --- | --- |\n| 1 | 2 |\n"
let json = """
{"type":"doc","content":[{"type":"table","content":[]}]}
"""
XCTAssertEqual(
EditorContentCodec.preferredMarkdown(
editorMarkdown: nil,
documentJSON: json,
fallback: fallback
),
fallback
)
}
func testEmptyEditorMarkdownRemainsAuthoritative() {
XCTAssertEqual(
EditorContentCodec.preferredMarkdown(
editorMarkdown: "",
documentJSON: #"{"type":"doc","content":[]}"#,
fallback: "previous content"
),
""
)
}
/// Flattened JSON + rich markdown must NOT drive the detail viewer (regression).
func testViewerAlwaysPrefersMarkdownWhenPresent() {
let richMD = """
+5 -1
View File
@@ -1801,6 +1801,8 @@ const CreateMemoModal = ({
const [dirty, setDirty] = useState(false);
const [editorReady, setEditorReady] = useState(false);
const [imageOperation, setImageOperation] = useState<"idle" | "creating" | "uploading">("idle");
const imageOperationRef = useRef(imageOperation);
const createPendingRef = useRef(false);
const [resourceTarget, setResourceTarget] = useState<MobileResourceTarget | null>(null);
const targetNotebookId = notebookId || fallbackNotebookId;
const selectedNotebookName = notebooks.find((notebook) => notebook.id === targetNotebookId)?.name ?? "选择笔记本";
@@ -1813,6 +1815,7 @@ const CreateMemoModal = ({
titleRef.current = title;
tagsTextRef.current = tagsText;
targetNotebookIdRef.current = targetNotebookId;
imageOperationRef.current = imageOperation;
const focusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const keyboardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -2093,6 +2096,7 @@ const CreateMemoModal = ({
onCreated(memo);
},
});
createPendingRef.current = createMutation.isPending;
const canSubmitCreateMemo = Boolean(targetNotebookId) && !createMutation.isPending && imageOperation === "idle";
const canUseTemplate = imageOperation === "idle" && !createMutation.isPending;
@@ -2193,7 +2197,7 @@ const CreateMemoModal = ({
};
const requestClose = async () => {
if (createMutation.isPending || imageOperation !== "idle") {
if (createPendingRef.current || imageOperationRef.current !== "idle") {
return;
}
await flushEditor();
+3
View File
@@ -163,6 +163,9 @@ export default defineConfig({
],
globIgnores: [
"index.html",
// Noto Sans SC is used only by the on-demand print entry. Precaching every
// CJK unicode-range shard adds ~4.5 MiB to every PWA installation.
"**/noto-sans-sc-*.woff2",
"**/*beautiful-mermaid*.js",
"**/*mermaid.core-*.js",
"**/vendor-mermaid-*.js",
+11 -1
View File
@@ -23,9 +23,17 @@ assert.doesNotMatch(precacheManifest, /\{url:"index\.html",/, "Current HTML must
assert.match(precacheManifest, /index\.html\?edgeever-offline-shell=/, "PWA must retain a versioned offline HTML shell");
const optionalDiagramPattern = /(?:beautiful-mermaid|vendor-mermaid|mermaid\.core|[^"']*Diagram-)[^"']*\.js/;
assert.doesNotMatch(precacheManifest, optionalDiagramPattern, "Optional diagram chunks must remain out of the initial PWA precache");
assert.doesNotMatch(precacheManifest, /noto-sans-sc-[^"']*\.woff2/, "Print-only Noto Sans SC shards must remain out of the PWA precache");
const entryCount = (precacheManifest.match(/\{url:/g) ?? []).length;
const precacheURLs = [...precacheManifest.matchAll(/\{url:"([^"]+)"/g)].map((match) => match[1]);
const entryCount = precacheURLs.length;
assert.ok(entryCount > 0, "Web service worker precache manifest must not be empty");
const precacheBytes = precacheURLs
.filter((url) => !url.startsWith("/") && !url.includes("?"))
.map((url) => statSync(join(distDirectory, url)).size)
.reduce((total, size) => total + size, 0);
const PRECACHE_BUDGET = 5 * 1024 * 1024;
assert.ok(precacheBytes <= PRECACHE_BUDGET, `PWA precache budget exceeded: ${precacheBytes} > ${PRECACHE_BUDGET}`);
const modulePreloads = indexHtml.match(/<link rel="modulepreload"[^>]+>/g)?.join("\n") ?? "";
const initialOptionalPattern = /vendor-code-highlight|vendor-D3|beautiful-mermaid|vendor-(?:mermaid|tiptap|prosemirror|floating)|ui-primitives|mermaid\.core|[^"']*Diagram-/;
assert.doesNotMatch(modulePreloads, initialOptionalPattern, "Optional editor and diagram chunks must remain out of the initial HTML modulepreload list");
@@ -60,6 +68,8 @@ assert.ok(
console.log(JSON.stringify({
ok: true,
precacheEntries: entryCount,
precacheBytes,
precacheBudget: PRECACHE_BUDGET,
initialModulePreloadBytes,
initialModulePreloadBudget: INITIAL_MODULE_PRELOAD_BUDGET,
largeDeferredChunks: largeChunks,
+4
View File
@@ -86,6 +86,10 @@ describe("mobile app scope", () => {
expect(memoDetailSource).toContain("SAFE_DOM_WEBVIEW_PROPS");
});
test("reads the latest create and upload state from the hardware-back handler", () => {
expect(workspaceSource).toContain("createPendingRef.current || imageOperationRef.current");
});
test("declares iOS privacy strings and full-screen phone-on-iPad presentation", () => {
const infoPlist = appJson.expo.ios?.infoPlist ?? {};
expect(appJson.expo.ios?.supportsTablet).toBe(false);