From af1085e0201dcd1d98b6ada776bb60591f519e61 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 28 Aug 2026 17:28:46 +0800 Subject: [PATCH] fix: complete titles while typing the first heading --- .../Workspace/MemoEditViewModel.swift | 2 -- .../MemoEditViewModelTests.swift | 13 +++++-- apps/mobile/src/screens/WorkspaceScreen.tsx | 12 +++---- apps/web/src/components/EditorPane.tsx | 34 ++++++++++++++---- .../MobileStandaloneTiptapEditor.tsx | 35 +++++++++++++++---- packages/shared/src/content-title.test.mjs | 15 +++++++- packages/shared/src/content.ts | 11 ++++++ 7 files changed, 96 insertions(+), 26 deletions(-) diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift index 83cbc0ba..173c42ac 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift @@ -55,7 +55,6 @@ final class MemoEditViewModel { private func deriveTitleIfEligible(from json: String) { guard titleDerivationEligible, - title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let data = json.data(using: .utf8), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let blocks = root["content"] as? [[String: Any]] @@ -75,7 +74,6 @@ final class MemoEditViewModel { (attrs["level"] as? NSNumber)?.intValue == 1 else { return } title = String(text.prefix(160)) - titleDerivationEligible = false return } } diff --git a/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift b/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift index f9ab8443..9feee2ee 100644 --- a/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift +++ b/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift @@ -38,7 +38,7 @@ final class MemoEditViewModelTests: XCTestCase { XCTAssertTrue(EditorContentCodec.jsonContainsResource(model.contentJSON, src: "/api/v1/resources/image/blob")) } - func testDerivesTitleOnceFromFirstNonEmptyH1() { + func testDerivesTitleWhileTypingFirstH1AndStopsNextSession() { let model = MemoEditViewModel() model.beginTitleDerivationSession() model.applyEditorPayload( @@ -51,7 +51,16 @@ final class MemoEditViewModelTests: XCTestCase { markdown: "# Renamed heading", json: "{\"type\":\"doc\",\"content\":[{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Renamed heading\"}]}]}" ) - XCTAssertEqual(model.title, "Product plan") + XCTAssertEqual(model.title, "Renamed heading") + + let reopened = MemoEditViewModel() + reopened.title = model.title + reopened.beginTitleDerivationSession() + reopened.applyEditorPayload( + markdown: "# Later edit", + json: "{\"type\":\"doc\",\"content\":[{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Later edit\"}]}]}" + ) + XCTAssertEqual(reopened.title, "Renamed heading") } func testDoesNotDeriveTitleAfterManualTitleEdit() { diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index 4a5246b3..0d74ee1d 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -70,7 +70,7 @@ import { Alert, Pressable, Text, TextInput } from "../components/LocalizedText"; import Markdown, { type ASTNode, type RenderRules } from "react-native-markdown-display"; import { SvgXml } from "react-native-svg"; import { ApiRequestError } from "@edgeever/client"; -import { buildGitHubFeedbackUrl, createExcerpt, DEFAULT_MEMO_TITLE, deriveMemoTitleFromContent, docToMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, resolveMemoContentDoc, type AuthUser, type MemoDetail, type MemoRevision, type MemoSummary, type Notebook, type TiptapDoc } from "@edgeever/shared"; +import { buildGitHubFeedbackUrl, createExcerpt, DEFAULT_MEMO_TITLE, deriveMemoTitleDuringInitialEdit, docToMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, resolveMemoContentDoc, type AuthUser, type MemoDetail, type MemoRevision, type MemoSummary, type Notebook, type TiptapDoc } from "@edgeever/shared"; import { MOBILE_UI_METRICS, getMobileCenteredScrollOffset, getMobileNotebookSearchVisibleIds, toggleMobileMemoFilterMode, toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui"; import { clearMobileMemoDraft, clearMobileNewMemoDraft, readMobileMemoDraft, writeMobileMemoDraft, type MobileMemoDraft } from "../lib/mobile-drafts"; import { @@ -2567,10 +2567,9 @@ const CreateMemoModal = ({ }} onChange={async (contentJson) => { contentJsonRef.current = contentJson; - if (titleDerivationEligibleRef.current && !titleRef.current.trim()) { - const derivedTitle = deriveMemoTitleFromContent(contentJson); + if (titleDerivationEligibleRef.current) { + const derivedTitle = deriveMemoTitleDuringInitialEdit(titleRef.current, contentJson, true); if (derivedTitle) { - titleDerivationEligibleRef.current = false; titleRef.current = derivedTitle; setTitle(derivedTitle); } @@ -2975,10 +2974,9 @@ const RichEditorModal = ({ } contentSnapshotRef.current = contentSnapshot; contentJsonRef.current = contentJson; - if (titleDerivationEligibleRef.current && !titleRef.current.trim()) { - const derivedTitle = deriveMemoTitleFromContent(contentJson); + if (titleDerivationEligibleRef.current) { + const derivedTitle = deriveMemoTitleDuringInitialEdit(titleRef.current, contentJson, true); if (derivedTitle) { - titleDerivationEligibleRef.current = false; titleRef.current = derivedTitle; setTitle(derivedTitle); } diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 7474a59d..e0eb4b2f 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -126,7 +126,7 @@ import { cn, formatDateTime, parseTagsText } from "@/lib/utils"; import { EDITOR_CONTENT_MAX_WIDTH, EDITOR_CONTENT_MAX_WIDTH_COLLAPSED } from "@/lib/workspace-ui"; import { countMemoCharacters, - deriveMemoTitleFromContent, + deriveMemoTitleDuringInitialEdit, docToMarkdown, MEMO_CONTENT_STYLE, markdownToDoc, @@ -956,6 +956,7 @@ const RichEditorPane = ({ const memoRef = useRef(memo); const titleRef = useRef(title); const titleDerivationEligibleRef = useRef(false); + const titleWasAutoDerivedRef = useRef(false); const editSessionRef = useRef(null); const editorRef = useRef(null); const editorCanvasInteractionVersionRef = useRef(0); @@ -1002,13 +1003,21 @@ const RichEditorPane = ({ }); titleRef.current = title; - const maybeDeriveMemoTitle = useCallback((contentJson: TiptapDoc) => { - if (!titleDerivationEligibleRef.current || titleRef.current.trim()) return titleRef.current; - const derivedTitle = deriveMemoTitleFromContent(contentJson); + const maybeDeriveMemoTitle = useCallback((contentJson: TiptapDoc, finalize = false) => { + if ( + !titleDerivationEligibleRef.current || + (titleRef.current.trim() && !titleWasAutoDerivedRef.current) + ) return titleRef.current; + const derivedTitle = deriveMemoTitleDuringInitialEdit( + titleRef.current, + contentJson, + titleWasAutoDerivedRef.current, + ); if (!derivedTitle) return titleRef.current; - titleDerivationEligibleRef.current = false; + titleWasAutoDerivedRef.current = true; titleRef.current = derivedTitle; setTitle(derivedTitle); + if (finalize) titleDerivationEligibleRef.current = false; return derivedTitle; }, []); slashCommandLabelsRef.current = { @@ -2192,7 +2201,12 @@ const RichEditorPane = ({ return; } - const refreshToolbar = () => setEditorStateVersion((version) => version + 1); + const refreshToolbar = () => { + setEditorStateVersion((version) => version + 1); + if (titleWasAutoDerivedRef.current && !editor.isActive("heading", { level: 1 })) { + titleDerivationEligibleRef.current = false; + } + }; editor.on("selectionUpdate", refreshToolbar); editor.on("transaction", refreshToolbar); @@ -2541,6 +2555,7 @@ const RichEditorPane = ({ editingMemoIdRef.current = null; setHasUnsavedChanges(false); titleDerivationEligibleRef.current = false; + titleWasAutoDerivedRef.current = false; titleRef.current = ""; setTitle(""); setTagsText(""); @@ -2710,6 +2725,7 @@ const RichEditorPane = ({ setTitle(nextTitle); titleRef.current = nextTitle; titleDerivationEligibleRef.current = !nextTitle.trim(); + titleWasAutoDerivedRef.current = false; setTagsText(nextTagsText); setMobilePlainText(nextMarkdown); setMarkdownSource(nextMarkdown); @@ -2798,7 +2814,10 @@ const RichEditorPane = ({ return; } const contentJson = editor.getJSON() as TiptapDoc; - const nextTitle = maybeDeriveMemoTitle(contentJson); + const nextTitle = maybeDeriveMemoTitle( + contentJson, + titleWasAutoDerivedRef.current && !editor.isActive("heading", { level: 1 }), + ); persistCurrentDraft(nextTitle); markDirty(); }; @@ -4590,6 +4609,7 @@ const RichEditorPane = ({ readOnly={effectiveReadOnly} onChange={(event) => { titleDerivationEligibleRef.current = false; + titleWasAutoDerivedRef.current = false; titleRef.current = event.target.value; setTitle(event.target.value); persistCurrentDraft(event.target.value, tagsText, getMobilePlainTextValue()); diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx index c85b9cb7..60867c2e 100644 --- a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx +++ b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx @@ -10,7 +10,7 @@ import { TaskItem, TaskList } from "@tiptap/extension-list"; import { mergeAttributes } from "@tiptap/core"; import Placeholder from "@tiptap/extension-placeholder"; import { TableKit } from "@tiptap/extension-table"; -import { createExcerpt, deriveMemoTitleFromContent, docToMarkdown, docToText, emptyDoc, getImageReferrerPolicy, isPdfAttachment, MergeDivider, type MemoDetail, type MemoEditSession, type Notebook, type TagSummary, type TiptapDoc } from "@edgeever/shared"; +import { createExcerpt, deriveMemoTitleDuringInitialEdit, docToMarkdown, docToText, emptyDoc, getImageReferrerPolicy, isPdfAttachment, MergeDivider, type MemoDetail, type MemoEditSession, type Notebook, type TagSummary, type TiptapDoc } from "@edgeever/shared"; import { createEdgeEverMathematics } from "@edgeever/shared/mathematics"; import { getMobileEditorInputAttributes, getMobileEditorPlaceholder } from "@edgeever/shared/mobile-editor"; import { @@ -94,6 +94,7 @@ export const MobileStandaloneTiptapEditor = ({ const [title, setTitle] = useState(""); const titleRef = useRef(""); const titleDerivationEligibleRef = useRef(false); + const titleWasAutoDerivedRef = useRef(false); const [tagsText, setTagsText] = useState(""); const tagsTextRef = useRef(""); const contentJsonRef = useRef(emptyDoc()); @@ -112,13 +113,21 @@ export const MobileStandaloneTiptapEditor = ({ const backgroundSavePendingRef = useRef(false); const imageInputRef = useRef(null); - const maybeDeriveMemoTitle = useCallback((contentJson: TiptapDoc) => { - if (!titleDerivationEligibleRef.current || titleRef.current.trim()) return; - const derivedTitle = deriveMemoTitleFromContent(contentJson); + const maybeDeriveMemoTitle = useCallback((contentJson: TiptapDoc, finalize = false) => { + if ( + !titleDerivationEligibleRef.current || + (titleRef.current.trim() && !titleWasAutoDerivedRef.current) + ) return; + const derivedTitle = deriveMemoTitleDuringInitialEdit( + titleRef.current, + contentJson, + titleWasAutoDerivedRef.current, + ); if (!derivedTitle) return; - titleDerivationEligibleRef.current = false; + titleWasAutoDerivedRef.current = true; titleRef.current = derivedTitle; setTitle(derivedTitle); + if (finalize) titleDerivationEligibleRef.current = false; }, []); const setSaveStateStable = useCallback((nextState: MobileEditorSaveState) => { @@ -238,7 +247,10 @@ export const MobileStandaloneTiptapEditor = ({ }, onUpdate: ({ editor: activeEditor }) => { contentJsonRef.current = activeEditor.getJSON() as TiptapDoc; - maybeDeriveMemoTitle(contentJsonRef.current); + maybeDeriveMemoTitle( + contentJsonRef.current, + titleWasAutoDerivedRef.current && !activeEditor.isActive("heading", { level: 1 }), + ); dirtyRef.current = true; persistLocalDraft(); @@ -263,7 +275,12 @@ export const MobileStandaloneTiptapEditor = ({ return; } - const refreshToolbar = () => setToolbarVersion((version) => version + 1); + const refreshToolbar = () => { + setToolbarVersion((version) => version + 1); + if (titleWasAutoDerivedRef.current && !editor.isActive("heading", { level: 1 })) { + titleDerivationEligibleRef.current = false; + } + }; editor.on("selectionUpdate", refreshToolbar); editor.on("transaction", refreshToolbar); @@ -557,6 +574,7 @@ export const MobileStandaloneTiptapEditor = ({ const handleTitleChange = (nextTitle: string) => { titleDerivationEligibleRef.current = false; + titleWasAutoDerivedRef.current = false; setTitle(nextTitle); titleRef.current = nextTitle; scheduleMetadataSave(); @@ -794,6 +812,7 @@ export const MobileStandaloneTiptapEditor = ({ setTitle(draft.title || ""); titleRef.current = draft.title || ""; titleDerivationEligibleRef.current = !titleRef.current.trim(); + titleWasAutoDerivedRef.current = false; setTagsText(draft.tagsText || ""); tagsTextRef.current = draft.tagsText || ""; contentJsonRef.current = draft.contentJson || emptyDoc(); @@ -808,6 +827,7 @@ export const MobileStandaloneTiptapEditor = ({ setTitle(queuedTitle); titleRef.current = queuedTitle; titleDerivationEligibleRef.current = !queuedTitle.trim(); + titleWasAutoDerivedRef.current = false; setTagsText(queuedTagsText); tagsTextRef.current = queuedTagsText; contentJsonRef.current = queuedPayload.contentJson || emptyDoc(); @@ -824,6 +844,7 @@ export const MobileStandaloneTiptapEditor = ({ setTitle(nextTitle); titleRef.current = nextTitle; titleDerivationEligibleRef.current = !nextTitle.trim(); + titleWasAutoDerivedRef.current = false; setTagsText(nextTagsText); tagsTextRef.current = nextTagsText; contentJsonRef.current = nextContentJson; diff --git a/packages/shared/src/content-title.test.mjs b/packages/shared/src/content-title.test.mjs index 0bf2fc59..0a820f10 100644 --- a/packages/shared/src/content-title.test.mjs +++ b/packages/shared/src/content-title.test.mjs @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { deriveMemoTitleFromContent, markdownToDoc } from "./content.ts"; +import { deriveMemoTitleDuringInitialEdit, deriveMemoTitleFromContent, markdownToDoc } from "./content.ts"; describe("deriveMemoTitleFromContent", () => { test("uses the first non-empty H1", () => { @@ -28,3 +28,16 @@ describe("deriveMemoTitleFromContent", () => { expect(deriveMemoTitleFromContent(markdownToDoc(`# ${"a".repeat(200)}`))).toHaveLength(160); }); }); + +describe("deriveMemoTitleDuringInitialEdit", () => { + test("keeps filling the title as the initial H1 is typed", () => { + const first = deriveMemoTitleDuringInitialEdit("", markdownToDoc("# 浏"), false); + expect(first).toBe("浏"); + expect(deriveMemoTitleDuringInitialEdit(first, markdownToDoc("# 浏览器真实回归"), true)) + .toBe("浏览器真实回归"); + }); + + test("does not replace a manually entered title", () => { + expect(deriveMemoTitleDuringInitialEdit("我的标题", markdownToDoc("# 正文标题"), false)).toBeNull(); + }); +}); diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index aed016e4..9c55094a 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -101,6 +101,17 @@ export const deriveMemoTitleFromContent = (doc: unknown): string | null => { return null; }; +/** Keeps an automatically generated title complete while the user is still + * typing the initial H1, without overriding a title they entered themselves. */ +export const deriveMemoTitleDuringInitialEdit = ( + currentTitle: string, + doc: unknown, + wasAutoDerived: boolean, +): string | null => { + if (currentTitle.trim() && !wasAutoDerived) return null; + return deriveMemoTitleFromContent(doc); +}; + export const resolveMergedMemoTitle = ( inputTitle: string | null | undefined, sourceMemos: Array<{ title: string | null | undefined }>,