fix: complete titles while typing the first heading

This commit is contained in:
tianma-if committed 2026-08-28 17:28:46 +08:00
1 parent ddc228dfce
commit af1085e020
7 files changed
+96 -26

No files matched your search

@@ -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
}
}
@@ -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() {
+5 -7
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, 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);
}
+27 -7
View File
@@ -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<MemoDetail | null>(memo);
const titleRef = useRef(title);
const titleDerivationEligibleRef = useRef(false);
const titleWasAutoDerivedRef = useRef(false);
const editSessionRef = useRef<MemoEditSession | null>(null);
const editorRef = useRef<Editor | null>(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());
@@ -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<TiptapDoc>(emptyDoc());
@@ -112,13 +113,21 @@ 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);
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;
+14 -1
View File
@@ -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();
});
});
+11
View File
@@ -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 }>,