From 36796994d53673549a3e401025822e746ff2ea1c Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 29 Jul 2026 10:05:11 +0800 Subject: [PATCH] fix: preserve pasted content in new memos --- apps/api/src/index.ts | 9 +++++---- apps/web/src/components/EditorPane.tsx | 10 +++++++--- apps/web/src/components/WorkspaceApp.tsx | 4 ++-- packages/shared/src/content.test.mjs | 11 ++++++++++- packages/shared/src/content.ts | 6 +++++- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 270dc2e1..cf6394fe 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -162,6 +162,7 @@ type MemoSummaryRow = { title: string | null; excerpt: string; content_text?: string | null; + content_markdown?: string | null; tags_json: string; is_pinned: number; is_archived: number; @@ -1534,7 +1535,7 @@ app.get("/api/v1/memos", async (c) => { ) SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, mc.revision, - mc.content_text + mc.content_text, mc.content_markdown FROM search_matches s INNER JOIN memos m ON m.id = s.memo_id INNER JOIN memo_contents mc ON mc.memo_id = m.id @@ -1587,7 +1588,7 @@ app.get("/api/v1/memos", async (c) => { c.env.storage.db.prepare( `SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, mc.revision, - mc.content_text + mc.content_text, mc.content_markdown FROM memos m INNER JOIN memo_contents mc ON mc.memo_id = m.id WHERE ${searchCursorConditions.join(" AND ")} @@ -1616,7 +1617,7 @@ app.get("/api/v1/memos", async (c) => { c.env.storage.db.prepare( `SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, mc.revision, - mc.content_text + mc.content_text, mc.content_markdown FROM memos m INNER JOIN memo_contents mc ON mc.memo_id = m.id WHERE ${cursorConditions.join(" AND ")} @@ -4443,7 +4444,7 @@ const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({ id: row.id, notebookId: row.notebook_id, title: row.title, - excerpt: row.excerpt || createExcerpt(row.content_text ?? ""), + excerpt: row.excerpt || createExcerpt(row.content_text ?? "") || createExcerpt(docToText(markdownToDoc(row.content_markdown ?? ""))), tags: parseJsonArray(row.tags_json), isPinned: Boolean(row.is_pinned), isArchived: Boolean(row.is_archived), diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 2cca5e2c..e0535364 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -1166,6 +1166,7 @@ const RichEditorPane = ({ const [tagsText, setTagsText] = useState(""); const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "queued" | "error" | "conflict">("idle"); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [hydratedEditorMemoId, setHydratedEditorMemoId] = useState(null); const [dirtyVersion, setDirtyVersion] = useState(0); const [, setEditorStateVersion] = useState(0); const [editorContentVersion, setEditorContentVersion] = useState(0); @@ -1413,7 +1414,7 @@ const RichEditorPane = ({ content: memo ? resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown) : { type: "doc", content: [{ type: "paragraph" }] }, - editable: Boolean(memo && !effectiveReadOnly), + editable: Boolean(memo && !effectiveReadOnly && hydratedEditorMemoId === memo.id), editorProps: { attributes: { class: "prose prose-slate max-w-none focus:outline-none min-h-[300px] px-4 py-3 sm:px-7", @@ -1887,6 +1888,7 @@ const RichEditorPane = ({ memoRef.current = null; editSessionRef.current = null; hydratedMemoIdRef.current = null; + setHydratedEditorMemoId(null); editingMemoIdRef.current = null; hasUnsavedChangesRef.current = false; setHasUnsavedChanges(false); @@ -1908,6 +1910,7 @@ const RichEditorPane = ({ if (!sameMemo) { hydratedMemoIdRef.current = null; + setHydratedEditorMemoId(null); } if (sameMemo && hasUnsavedChangesRef.current && !memo.isDeleted) { @@ -1969,6 +1972,7 @@ const RichEditorPane = ({ hydratedMemoIdRef.current = memo.id; editSessionRef.current = editSessionResponse?.editSession ?? (requiresLocalEditSession(memo) ? createLocalEditSession(memo) : null); + setHydratedEditorMemoId(memo.id); window.setTimeout(() => { hydratingRef.current = false; @@ -2001,9 +2005,9 @@ const RichEditorPane = ({ useEffect(() => { if (isEditorReady(editor)) { - editor.setEditable(Boolean(memo && !effectiveReadOnly)); + editor.setEditable(Boolean(memo && !effectiveReadOnly && hydratedEditorMemoId === memo.id)); } - }, [editor, effectiveReadOnly, memo]); + }, [editor, effectiveReadOnly, hydratedEditorMemoId, memo]); useEffect(() => { if (!isEditorReady(editor) || !memo) { diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 53a4f22a..d8c0ff35 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -38,7 +38,7 @@ import { } from "@/lib/mobile-editor"; import { cn } from "@/lib/utils"; import { isBrowserOffline, isBrowserOnline, verifyBrowserConnectivity } from "@/lib/network-status"; -import { createExcerpt, docToText, getNotebookDescendantIds, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type Resource, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; +import { createExcerpt, docToText, getNotebookDescendantIds, resolveMemoContentDoc, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type Resource, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui"; import type { Pane, @@ -171,7 +171,7 @@ const memoToSummary = (memo: MemoDetail): MemoSummary => ({ id: memo.id, notebookId: memo.notebookId, title: memo.title, - excerpt: memo.excerpt || createExcerpt(memo.contentText || docToText(memo.contentJson) || memo.contentMarkdown), + excerpt: memo.excerpt || createExcerpt(memo.contentText || docToText(resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown))), tags: memo.tags, isPinned: memo.isPinned, isArchived: memo.isArchived, diff --git a/packages/shared/src/content.test.mjs b/packages/shared/src/content.test.mjs index 21e8d3b1..d9fd6d57 100644 --- a/packages/shared/src/content.test.mjs +++ b/packages/shared/src/content.test.mjs @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { countMemoCharacters, docToMarkdown, markdownToDoc, resolveMemoContentDoc } from "./content.ts"; +import { countMemoCharacters, docToMarkdown, docToText, markdownToDoc, resolveMemoContentDoc } from "./content.ts"; describe("memo character count", () => { test("counts punctuation while excluding whitespace and formatting", () => { @@ -59,6 +59,15 @@ describe("Markdown table conversion", () => { }); }); +describe("legacy Markdown body recovery", () => { + test("recovers a body when the stored JSON document is empty", () => { + const markdown = "权威和反中心化特征,即当一个人成为某种代表性的符号之后"; + const resolved = resolveMemoContentDoc(markdownToDoc(""), markdown); + + expect(docToText(resolved)).toContain(markdown); + }); +}); + describe("Nested list Markdown conversion", () => { const markdown = [ "- Level 1", diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 9d4ee765..70a13c80 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -77,7 +77,11 @@ export const resolveMemoContentDoc = ( } const markdownDoc = markdownToDoc(contentMarkdown); - return docContainsNodeType(markdownDoc, "table") ? markdownDoc : currentDoc; + // Some older saves left an empty JSON document behind while retaining the + // real body in Markdown. Treat that as a compatibility case too; otherwise + // the editor can show the Markdown body while list excerpts see an empty + // JSON document. + return docContainsNodeType(markdownDoc, "table") || !docToText(currentDoc) ? markdownDoc : currentDoc; }; const LEGACY_ATTACHMENT_PATTERN = /^(附件:|Attachment:\s*)(.+?)\s+(\/api\/v1\/resources\/\S+|https?:\/\/\S+)$/;