feat: derive empty note titles from first heading
This commit is contained in:
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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user