refactor: harden mobile editing and PWA caching
This commit is contained in:
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))\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
+281
-279
File diff suppressed because it is too large.
Load diff
@@ -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 = """
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user