feat: derive empty note titles from first heading

This commit is contained in:
tianma-if committed 2026-08-28 17:16:01 +08:00
1 parent 69ee9c3823
commit ddc228dfce
8 files changed
+209 -8

No files matched your search

@@ -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.
@@ -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<Void, Never>?
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 {
@@ -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()
+23 -1
View File
@@ -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 = ({
<View style={styles.richEditorContainer}>
<TextInput
onChangeText={(value) => {
titleDerivationEligibleRef.current = false;
setTitle(value);
dirtyRef.current = true;
setDirty(true);
+29 -4
View File
@@ -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<MemoDetail | null>(memo);
const titleRef = useRef(title);
const titleDerivationEligibleRef = useRef(false);
const editSessionRef = useRef<MemoEditSession | null>(null);
const editorRef = useRef<Editor | null>(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();
@@ -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<TiptapDoc>(emptyDoc());
@@ -111,6 +112,15 @@ export const MobileStandaloneTiptapEditor = ({
const backgroundSavePendingRef = useRef(false);
const imageInputRef = useRef<HTMLInputElement | null>(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;
@@ -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);
});
});
+34
View File
@@ -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<string, unknown> };
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,