From ddc228dfce954d0923f2a0c0615bb615c54d7d15 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 28 Aug 2026 17:16:01 +0800 Subject: [PATCH] feat: derive empty note titles from first heading --- .../Features/Workspace/MemoEditView.swift | 8 +++- .../Workspace/MemoEditViewModel.swift | 44 +++++++++++++++++++ .../MemoEditViewModelTests.swift | 27 ++++++++++++ apps/mobile/src/screens/WorkspaceScreen.tsx | 24 +++++++++- apps/web/src/components/EditorPane.tsx | 33 ++++++++++++-- .../MobileStandaloneTiptapEditor.tsx | 17 ++++++- packages/shared/src/content-title.test.mjs | 30 +++++++++++++ packages/shared/src/content.ts | 34 ++++++++++++++ 8 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/content-title.test.mjs diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift index ed24111f..4855c2b7 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift @@ -289,6 +289,7 @@ struct MemoEditView: View { } .task { await loadInitial() + viewModel.beginTitleDerivationSession() contentHydrated = true // editorReady flips true from TipTap onBodyReady (or fallback below). try? await Task.sleep(nanoseconds: 800_000_000) @@ -428,7 +429,10 @@ struct MemoEditView: View { env.preferences.t("无标题笔记", en: "Untitled note"), text: Binding( get: { viewModel.title }, - set: { viewModel.title = $0 } + set: { + viewModel.userEditedTitle($0) + markDirtyAndScheduleSave() + } ) ) .font(.system(size: 28, weight: .heavy)) @@ -436,7 +440,6 @@ struct MemoEditView: View { .textFieldStyle(.plain) .padding(.top, 14) .padding(.bottom, 8) - .onChange(of: title) { _, _ in markDirtyAndScheduleSave() } .accessibilityLabel(env.preferences.t("笔记标题", en: "Note title")) .accessibilityIdentifier(CreateMemoChrome.title) @@ -816,6 +819,7 @@ struct MemoEditView: View { private func applyTemplateSeed(_ seed: CreateMemoSeed) { title = seed.title + viewModel.beginTitleDerivationSession() tagsText = seed.tagsText contentMarkdown = seed.contentMarkdown // Empty stub JSON forces TipTapContentSource to open from markdown structure. diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift index 52546774..83cbc0ba 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditViewModel.swift @@ -25,6 +25,7 @@ final class MemoEditViewModel { var suppressPersistence = false var contentHydrated = false var baselineMarkdown = "" + private(set) var titleDerivationEligible = false @ObservationIgnored private var saveTask: Task? var tags: [String] { @@ -43,6 +44,48 @@ final class MemoEditViewModel { isDirty = true } + func beginTitleDerivationSession() { + titleDerivationEligible = title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func userEditedTitle(_ nextTitle: String) { + titleDerivationEligible = false + title = nextTitle + } + + 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]] + else { return } + + for block in blocks { + let text = Self.nodeText(block) + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + if text.isEmpty { + let type = block["type"] as? String + if type == "paragraph" || type == "heading" { continue } + return + } + 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)) + titleDerivationEligible = false + return + } + } + + private static func nodeText(_ node: [String: Any]) -> String { + let own = node["text"] as? String ?? "" + let children = (node["content"] as? [[String: Any]] ?? []).map(nodeText).joined() + return own + children + } + func scheduleSave( delayNanoseconds: UInt64 = 500_000_000, operation: @escaping @MainActor () async -> Void @@ -145,6 +188,7 @@ final class MemoEditViewModel { contentJSON = nextJSON contentMarkdown = nextMarkdown + deriveTitleIfEligible(from: nextJSON) } func wouldClobberNonEmptyBody(isCreate: Bool) -> Bool { diff --git a/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift b/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift index 2219bc5d..f9ab8443 100644 --- a/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift +++ b/apps/ios/Tests/EdgeEverTests/MemoEditViewModelTests.swift @@ -38,6 +38,33 @@ final class MemoEditViewModelTests: XCTestCase { XCTAssertTrue(EditorContentCodec.jsonContainsResource(model.contentJSON, src: "/api/v1/resources/image/blob")) } + func testDerivesTitleOnceFromFirstNonEmptyH1() { + let model = MemoEditViewModel() + model.beginTitleDerivationSession() + model.applyEditorPayload( + markdown: "# Product plan\n\nBody", + json: "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"},{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Product plan\"}]}]}" + ) + XCTAssertEqual(model.title, "Product plan") + + model.applyEditorPayload( + markdown: "# Renamed heading", + json: "{\"type\":\"doc\",\"content\":[{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Renamed heading\"}]}]}" + ) + XCTAssertEqual(model.title, "Product plan") + } + + func testDoesNotDeriveTitleAfterManualTitleEdit() { + let model = MemoEditViewModel() + model.beginTitleDerivationSession() + model.userEditedTitle("") + model.applyEditorPayload( + markdown: "# Product plan", + json: "{\"type\":\"doc\",\"content\":[{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"type\":\"text\",\"text\":\"Product plan\"}]}]}" + ) + XCTAssertEqual(model.title, "") + } + func testDebouncedSaveKeepsOnlyLatestOperation() async { let model = MemoEditViewModel() model.markDirty() diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index c02a1889..4a5246b3 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, 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, deriveMemoTitleFromContent, 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 { @@ -2136,6 +2136,7 @@ const CreateMemoModal = ({ const targetNotebookId = notebookId || fallbackNotebookId; const selectedNotebookName = notebooks.find((notebook) => notebook.id === targetNotebookId)?.name ?? "选择笔记本"; const titleRef = useRef(title); + const titleDerivationEligibleRef = useRef(!initialDraft?.title.trim()); const tagsTextRef = useRef(tagsText); const targetNotebookIdRef = useRef(targetNotebookId); const userEditedSinceOpenRef = useRef(false); @@ -2270,6 +2271,8 @@ const CreateMemoModal = ({ contentMarkdownRef.current = markdown; contentJsonRef.current = doc; setTitle(seed.title); + titleRef.current = seed.title; + titleDerivationEligibleRef.current = !seed.title.trim(); setTagsText(seed.tagsText); setContentMarkdown(markdown); draftVersionRef.current += 1; @@ -2564,6 +2567,14 @@ const CreateMemoModal = ({ }} onChange={async (contentJson) => { contentJsonRef.current = contentJson; + if (titleDerivationEligibleRef.current && !titleRef.current.trim()) { + const derivedTitle = deriveMemoTitleFromContent(contentJson); + if (derivedTitle) { + titleDerivationEligibleRef.current = false; + titleRef.current = derivedTitle; + setTitle(derivedTitle); + } + } const markdown = docToMarkdown(contentJson); contentMarkdownRef.current = markdown; setContentMarkdown(markdown); @@ -2625,6 +2636,7 @@ const CreateMemoModal = ({ autoCorrect accessibilityLabel="笔记标题" onChangeText={(value) => { + titleDerivationEligibleRef.current = false; setTitle(value); markDirty(); }} @@ -2925,6 +2937,7 @@ const RichEditorModal = ({ const notebookLabel = notebooks.find((notebook) => notebook.id === notebookId)?.name ?? "未分类"; const saveLabel = error ? "保存失败" : saving ? "保存中" : uploading ? "上传中" : dirty ? (draftRestored ? "本地草稿" : "未保存") : ready ? "已保存" : "加载中"; const titleRef = useRef(title); + const titleDerivationEligibleRef = useRef(!title.trim()); const tagsTextRef = useRef(tagsText); const notebookIdRef = useRef(notebookId); titleRef.current = title; @@ -2962,6 +2975,14 @@ const RichEditorModal = ({ } contentSnapshotRef.current = contentSnapshot; contentJsonRef.current = contentJson; + if (titleDerivationEligibleRef.current && !titleRef.current.trim()) { + const derivedTitle = deriveMemoTitleFromContent(contentJson); + if (derivedTitle) { + titleDerivationEligibleRef.current = false; + titleRef.current = derivedTitle; + setTitle(derivedTitle); + } + } contentMarkdownRef.current = docToMarkdown(contentJson); dirtyRef.current = true; setDirty(true); @@ -3219,6 +3240,7 @@ const RichEditorModal = ({ { + titleDerivationEligibleRef.current = false; setTitle(value); dirtyRef.current = true; setDirty(true); diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 923e9180..7474a59d 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -126,6 +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, docToMarkdown, MEMO_CONTENT_STYLE, markdownToDoc, @@ -953,6 +954,8 @@ const RichEditorPane = ({ }, [desktopReadingProtection]); const memoRef = useRef(memo); + const titleRef = useRef(title); + const titleDerivationEligibleRef = useRef(false); const editSessionRef = useRef(null); const editorRef = useRef(null); const editorCanvasInteractionVersionRef = useRef(0); @@ -997,6 +1000,17 @@ const RichEditorPane = ({ "external-link": "", }, }); + titleRef.current = title; + + const maybeDeriveMemoTitle = useCallback((contentJson: TiptapDoc) => { + if (!titleDerivationEligibleRef.current || titleRef.current.trim()) return titleRef.current; + const derivedTitle = deriveMemoTitleFromContent(contentJson); + if (!derivedTitle) return titleRef.current; + titleDerivationEligibleRef.current = false; + titleRef.current = derivedTitle; + setTitle(derivedTitle); + return derivedTitle; + }, []); slashCommandLabelsRef.current = { menu: t("slashMenu.menu"), empty: t("slashMenu.empty"), @@ -2526,6 +2540,8 @@ const RichEditorPane = ({ setHydratedEditorMemoId(null); editingMemoIdRef.current = null; setHasUnsavedChanges(false); + titleDerivationEligibleRef.current = false; + titleRef.current = ""; setTitle(""); setTagsText(""); setMobilePlainText(""); @@ -2692,6 +2708,8 @@ const RichEditorPane = ({ setSaveConflictInfo(null); } setTitle(nextTitle); + titleRef.current = nextTitle; + titleDerivationEligibleRef.current = !nextTitle.trim(); setTagsText(nextTagsText); setMobilePlainText(nextMarkdown); setMarkdownSource(nextMarkdown); @@ -2779,7 +2797,9 @@ const RichEditorPane = ({ if (hydratingRef.current || memoRef.current?.isDeleted) { return; } - persistCurrentDraft(); + const contentJson = editor.getJSON() as TiptapDoc; + const nextTitle = maybeDeriveMemoTitle(contentJson); + persistCurrentDraft(nextTitle); markDirty(); }; @@ -2787,7 +2807,7 @@ const RichEditorPane = ({ return () => { editor.off("update", persistDraft); }; - }, [editor, markDirty, memo, persistCurrentDraft]); + }, [editor, markDirty, maybeDeriveMemoTitle, memo, persistCurrentDraft]); useEffect(() => { const advanceMemoSyncBase = (syncedMemo: MemoDetail | null | undefined) => { @@ -2901,8 +2921,9 @@ const RichEditorPane = ({ const handleMarkdownSourceChange = useCallback((value: string) => { setMarkdownSource(value); + maybeDeriveMemoTitle(markdownToDoc(value)); markDirty(); - }, [markDirty]); + }, [markDirty, maybeDeriveMemoTitle]); const handleCopyToWeChat = useCallback(async () => { if (!isEditorReady(editor)) { @@ -3659,6 +3680,7 @@ const RichEditorPane = ({ }; const handleNativeInput = (event: Event) => { mobileImeDebugRecorderRef.current(event.type, event); + maybeDeriveMemoTitle(markdownToDoc(getMobilePlainTextElementValue(plainTextElement))); markMobilePlainTextDirtyRef.current(); }; @@ -3682,7 +3704,7 @@ const RichEditorPane = ({ plainTextElement.removeEventListener("compositionend", recordNativeEvent); plainTextElement.removeEventListener("input", handleNativeInput); }; - }, [useMobilePlainTextEditor]); + }, [maybeDeriveMemoTitle, useMobilePlainTextEditor]); useEffect(() => () => clearMobileEditorTimers(), [clearMobileEditorTimers]); @@ -3959,6 +3981,7 @@ const RichEditorPane = ({ const nextValue = `${currentText}${currentText ? "\n" : ""}${nextText}`; setMobilePlainText(nextValue); setMobilePlainTextElementValue(mobileTextAreaRef.current, nextValue); + maybeDeriveMemoTitle(markdownToDoc(nextValue)); markMobilePlainTextDirty(); recordMobileImeDebugEvent(eventName); window.requestAnimationFrame(() => focusMobileInputTarget()); @@ -4566,6 +4589,8 @@ const RichEditorPane = ({ value={title} readOnly={effectiveReadOnly} onChange={(event) => { + titleDerivationEligibleRef.current = false; + titleRef.current = event.target.value; setTitle(event.target.value); persistCurrentDraft(event.target.value, tagsText, getMobilePlainTextValue()); markDirty(); diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx index 11162eac..c85b9cb7 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, docToMarkdown, docToText, emptyDoc, getImageReferrerPolicy, isPdfAttachment, MergeDivider, type MemoDetail, type MemoEditSession, type Notebook, type TagSummary, type TiptapDoc } from "@edgeever/shared"; +import { createExcerpt, deriveMemoTitleFromContent, 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 { @@ -93,6 +93,7 @@ export const MobileStandaloneTiptapEditor = ({ const [notebookSheetOpen, setNotebookSheetOpen] = useState(false); const [title, setTitle] = useState(""); const titleRef = useRef(""); + const titleDerivationEligibleRef = useRef(false); const [tagsText, setTagsText] = useState(""); const tagsTextRef = useRef(""); const contentJsonRef = useRef(emptyDoc()); @@ -111,6 +112,15 @@ 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); + if (!derivedTitle) return; + titleDerivationEligibleRef.current = false; + titleRef.current = derivedTitle; + setTitle(derivedTitle); + }, []); + const setSaveStateStable = useCallback((nextState: MobileEditorSaveState) => { if (saveStateRef.current === nextState) { return; @@ -228,6 +238,7 @@ export const MobileStandaloneTiptapEditor = ({ }, onUpdate: ({ editor: activeEditor }) => { contentJsonRef.current = activeEditor.getJSON() as TiptapDoc; + maybeDeriveMemoTitle(contentJsonRef.current); dirtyRef.current = true; persistLocalDraft(); @@ -545,6 +556,7 @@ export const MobileStandaloneTiptapEditor = ({ }, [navigateBack, persistLocalDraft, saveNow, setSaveStateStable]); const handleTitleChange = (nextTitle: string) => { + titleDerivationEligibleRef.current = false; setTitle(nextTitle); titleRef.current = nextTitle; scheduleMetadataSave(); @@ -781,6 +793,7 @@ export const MobileStandaloneTiptapEditor = ({ if (useDraft && draft) { setTitle(draft.title || ""); titleRef.current = draft.title || ""; + titleDerivationEligibleRef.current = !titleRef.current.trim(); setTagsText(draft.tagsText || ""); tagsTextRef.current = draft.tagsText || ""; contentJsonRef.current = draft.contentJson || emptyDoc(); @@ -794,6 +807,7 @@ export const MobileStandaloneTiptapEditor = ({ const queuedTagsText = queuedPayload.tags.join(", "); setTitle(queuedTitle); titleRef.current = queuedTitle; + titleDerivationEligibleRef.current = !queuedTitle.trim(); setTagsText(queuedTagsText); tagsTextRef.current = queuedTagsText; contentJsonRef.current = queuedPayload.contentJson || emptyDoc(); @@ -809,6 +823,7 @@ export const MobileStandaloneTiptapEditor = ({ } else { setTitle(nextTitle); titleRef.current = nextTitle; + titleDerivationEligibleRef.current = !nextTitle.trim(); 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 new file mode 100644 index 00000000..0bf2fc59 --- /dev/null +++ b/packages/shared/src/content-title.test.mjs @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { deriveMemoTitleFromContent, markdownToDoc } from "./content.ts"; + +describe("deriveMemoTitleFromContent", () => { + test("uses the first non-empty H1", () => { + expect(deriveMemoTitleFromContent(markdownToDoc("\n# Product plan \n\nBody"))).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("does not skip meaningful non-text blocks 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" }] }, + ], + })).toBeNull(); + }); + + test("limits generated titles to the persisted title length", () => { + expect(deriveMemoTitleFromContent(markdownToDoc(`# ${"a".repeat(200)}`))).toHaveLength(160); + }); +}); diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 42351f7f..aed016e4 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -66,6 +66,40 @@ export type TiptapDoc = { }; export const DEFAULT_MEMO_TITLE = "无标题笔记"; +export const MEMO_TITLE_MAX_LENGTH = 160; + +const nodeText = (node: unknown): string => { + if (!node || typeof node !== "object") return ""; + const current = node as { text?: unknown; content?: unknown }; + const ownText = typeof current.text === "string" ? current.text : ""; + const childText = Array.isArray(current.content) ? current.content.map(nodeText).join("") : ""; + return `${ownText}${childText}`; +}; + +/** + * Derives a note title only when the first non-empty top-level content block is + * an H1. Callers own the one-shot edit-session policy; this helper only + * determines whether the current document is eligible. + */ +export const deriveMemoTitleFromContent = (doc: unknown): string | null => { + if (!doc || typeof doc !== "object") return null; + const content = (doc as { content?: unknown }).content; + if (!Array.isArray(content)) return null; + + for (const value of content) { + if (!value || typeof value !== "object") continue; + const node = value as { type?: unknown; attrs?: Record }; + const text = nodeText(node).replace(/\s+/g, " ").trim(); + 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; +}; export const resolveMergedMemoTitle = ( inputTitle: string | null | undefined,