fix: complete titles while typing the first heading
This commit is contained in:
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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }>,
|
||||
|
||||
Reference in new issue
Block a user