diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift index 859115e6..7b4e426e 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift @@ -64,13 +64,16 @@ final class MemoEditViewModel { let text = Self.nodeText(block) .split(whereSeparator: { $0.isWhitespace }) .joined(separator: " ") - if block["type"] as? String == "heading", - let attrs = block["attrs"] as? [String: Any], - (attrs["level"] as? NSNumber)?.intValue == 1, - !text.isEmpty { - title = String(text.prefix(160)) - return + if text.isEmpty { + let type = block["type"] as? String + if type == "paragraph" || type == "heading" { continue } } + guard block["type"] as? String == "heading", + let attrs = block["attrs"] as? [String: Any], + (attrs["level"] as? NSNumber)?.intValue == 1 + else { return } + title = String(text.prefix(160)) + return } } diff --git a/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift b/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift index da77e4ad..8aa1e97e 100644 --- a/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift +++ b/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift @@ -43,7 +43,7 @@ final class MemoEditViewModelTests: XCTestCase { model.beginTitleDerivationSession() model.applyEditorPayload( markdown: "# Product plan\n\nBody", - json: "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Intro\"}]},{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Product plan\"}]}]}" + json: "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"},{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Product plan\"}]}]}" ) XCTAssertEqual(model.title, "Product plan") @@ -63,6 +63,16 @@ final class MemoEditViewModelTests: XCTestCase { XCTAssertEqual(reopened.title, "Renamed heading") } + func testDoesNotDeriveTitleFromH1AfterBodyContent() { + let model = MemoEditViewModel() + model.beginTitleDerivationSession() + model.applyEditorPayload( + markdown: "Intro\n\n# Product plan", + json: "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Intro\"}]},{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Product plan\"}]}]}" + ) + XCTAssertEqual(model.title, "") + } + func testDoesNotDeriveTitleAfterManualTitleEdit() { let model = MemoEditViewModel() model.beginTitleDerivationSession() diff --git a/packages/shared/src/content-title.test.mjs b/packages/shared/src/content-title.test.mjs index 71599cad..14f96112 100644 --- a/packages/shared/src/content-title.test.mjs +++ b/packages/shared/src/content-title.test.mjs @@ -6,22 +6,22 @@ describe("deriveMemoTitleFromContent", () => { expect(deriveMemoTitleFromContent(markdownToDoc("\n# Product plan \n\nBody"))).toBe("Product plan"); }); - test("uses the first H1 even when body content comes first", () => { - expect(deriveMemoTitleFromContent(markdownToDoc("Intro\n\n# Product plan"))).toBe("Product plan"); + test("does not use a later H1 when body content comes first", () => { + expect(deriveMemoTitleFromContent(markdownToDoc("Intro\n\n# Product plan"))).toBeNull(); }); test("does not use lower-level headings", () => { expect(deriveMemoTitleFromContent(markdownToDoc("## Product plan"))).toBeNull(); }); - test("uses the first H1 after a non-text block", () => { + test("does not skip a meaningful non-text block before the H1", () => { expect(deriveMemoTitleFromContent({ type: "doc", content: [ { type: "image", attrs: { src: "/cover.png" } }, { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Product plan" }] }, ], - })).toBe("Product plan"); + })).toBeNull(); }); test("limits generated titles to the persisted title length", () => { diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index a50d6879..2e17730b 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -77,8 +77,9 @@ const nodeText = (node: unknown): string => { }; /** - * Derives a note title from the first non-empty top-level H1. Callers own the - * one-shot edit-session policy; this helper only finds the heading text. + * Derives a note title only when the first meaningful top-level block is an + * H1. Empty text blocks before it are ignored; body content prevents a later + * section heading from unexpectedly renaming the note. */ export const deriveMemoTitleFromContent = (doc: unknown): string | null => { if (!doc || typeof doc !== "object") return null; @@ -89,9 +90,12 @@ export const deriveMemoTitleFromContent = (doc: unknown): string | null => { if (!value || typeof value !== "object") continue; const node = value as { type?: unknown; attrs?: Record }; const text = nodeText(node).replace(/\s+/g, " ").trim(); - if (node.type === "heading" && node.attrs?.level === 1 && text) { - return text.slice(0, MEMO_TITLE_MAX_LENGTH); + if (!text) { + if (node.type === "paragraph" || node.type === "heading") continue; + return null; } + if (node.type !== "heading" || node.attrs?.level !== 1) return null; + return text.slice(0, MEMO_TITLE_MAX_LENGTH); } return null; diff --git a/tests/e2e/new-memo-sync.spec.ts b/tests/e2e/new-memo-sync.spec.ts index 5093b433..e62c50fd 100644 --- a/tests/e2e/new-memo-sync.spec.ts +++ b/tests/e2e/new-memo-sync.spec.ts @@ -177,8 +177,38 @@ const finishSyncAndVerifyReload = async ( }; test.describe("new memo synchronization", () => { - test("derives the empty title from a complete H1 after body paragraphs", async ({ page }) => { - const derivedTitle = `后置一级标题 ${Date.now()}`; + test("derives the complete title when the first meaningful block is an H1", async ({ page }) => { + const derivedTitle = `首个一级标题 ${Date.now()}`; + const createResponsePromise = page.waitForResponse( + (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/v1/memos", + ); + + await page.goto("/"); + await page.getByRole("button", { name: "新建笔记", exact: true }).click(); + const createResponse = await createResponsePromise; + const created = await createResponse.json() as { memo: { id: string } }; + const editor = page.locator(".ProseMirror[contenteditable='true']"); + + try { + await editor.click(); + await page.keyboard.type("# "); + await page.keyboard.insertText(derivedTitle); + + await expect(page.getByPlaceholder("无标题")).toHaveValue(derivedTitle); + await expect.poll(async () => { + const response = await page.request.get(`/api/v1/memos/${created.memo.id}`); + if (!response.ok()) return null; + const body = await response.json() as { memo: { title: string | null } }; + return body.memo.title; + }).toBe(derivedTitle); + } finally { + await page.request.delete(`/api/v1/memos/${created.memo.id}`); + await page.request.delete(`/api/v1/memos/${created.memo.id}?permanent=1`); + } + }); + + test("does not derive the title from an H1 after body paragraphs", async ({ page }) => { + const sectionHeading = `后置一级标题 ${Date.now()}`; const createResponsePromise = page.waitForResponse( (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/v1/memos", ); @@ -197,15 +227,15 @@ test.describe("new memo synchronization", () => { await page.keyboard.insertText("第二段正文"); await page.keyboard.press("Enter"); await page.keyboard.type("# "); - await page.keyboard.insertText(derivedTitle); + await page.keyboard.insertText(sectionHeading); - await expect(titleInput).toHaveValue(derivedTitle); + await expect(titleInput).toHaveValue(""); await expect.poll(async () => { const response = await page.request.get(`/api/v1/memos/${created.memo.id}`); if (!response.ok()) return null; const body = await response.json() as { memo: { title: string | null } }; return body.memo.title; - }).toBe(derivedTitle); + }).toBe("无标题笔记"); } finally { await page.request.delete(`/api/v1/memos/${created.memo.id}`); await page.request.delete(`/api/v1/memos/${created.memo.id}?permanent=1`);