From 65ef837f4561ba84c16d4dd8462be0d87dcd2b34 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 30 Sep 2026 20:12:49 +0800 Subject: [PATCH] feat(web): copy a note to WeChat from the card menu Text-note card menus can copy the note as rich text. The open note uses the editor, including unsaved changes; other notes copy saved content without switching the editor. --- apps/web/src/components/MemoListPane.tsx | 32 ++++++++++++++++++- apps/web/src/components/WorkspaceApp.tsx | 24 ++++++++++++++ .../editor/editor-document-actions.test.mjs | 4 ++- .../editor/editor-document-actions.ts | 4 +++ .../editor/useEditorDocumentActions.ts | 2 ++ apps/web/src/lib/app-helpers.ts | 4 ++- tests/memo-list-wechat-copy.test.ts | 24 ++++++++++++++ 7 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 tests/memo-list-wechat-copy.test.ts diff --git a/apps/web/src/components/MemoListPane.tsx b/apps/web/src/components/MemoListPane.tsx index 8532fff1..cbe3b41c 100644 --- a/apps/web/src/components/MemoListPane.tsx +++ b/apps/web/src/components/MemoListPane.tsx @@ -51,6 +51,7 @@ import { Pencil, Copy, } from "lucide-react"; +import { WeChatIcon } from "./WeChatIcon"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -75,6 +76,7 @@ import type { MemoListDensity, MemoContextMenuState, MemoDocumentAction, + MemoWeChatCopyResult, NotebookMoveOption, } from "@/lib/app-helpers"; import { contentEnterMotion, paneEnterMotion } from "@/lib/motion"; @@ -392,6 +394,7 @@ export const MemoListPane = ({ onTogglePinMemo, onMoveMemo, onRequestDocumentAction, + onCopyMemoToWeChat, onMoveSelectedMemos, onPinSelectedMemos, onExportSelectedMemos, @@ -471,6 +474,7 @@ export const MemoListPane = ({ onTogglePinMemo: (memo: MemoSummary) => void; onMoveMemo: (memoId: string, notebookId: string) => void; onRequestDocumentAction: (memoId: string, action: MemoDocumentAction, printWindow?: Window | null) => void; + onCopyMemoToWeChat: (memoId: string) => Promise; onMoveSelectedMemos: (notebookId: string) => void; onPinSelectedMemos: (pinned: boolean) => void; onExportSelectedMemos: () => void; @@ -515,6 +519,7 @@ export const MemoListPane = ({ const [lastSelectedMemoId, setLastSelectedMemoId] = useState(null); const [moveTargetNotebookId, setMoveTargetNotebookId] = useState(""); const [memoIdCopyNotice, setMemoIdCopyNotice] = useState<{ status: "copied" | "error"; id: string } | null>(null); + const [wechatCopyNotice, setWechatCopyNotice] = useState<"copied" | "error" | null>(null); const [fileDragActive, setFileDragActive] = useState(false); const filterOptions = useMemo(() => getMemoFilterOptions(t), [t]); @@ -655,6 +660,16 @@ export const MemoListPane = ({ window.setTimeout(() => setMemoIdCopyNotice(null), copied ? 2200 : 3000); }; + const handleCopyContextMemoToWeChat = async () => { + const memo = memoContextMenu?.memo; + if (!memo || memo.diagramKind || memo.structuredTable || memo.infographic) return; + setMemoContextMenu(null); + const result = await onCopyMemoToWeChat(memo.id); + if (result === "editor") return; + setWechatCopyNotice(result); + window.setTimeout(() => setWechatCopyNotice(null), result === "copied" ? 2200 : 2600); + }; + useEffect(() => { const nextTargetId = resolveSelectionMoveTargetNotebookId( moveTargetNotebookId, @@ -875,7 +890,7 @@ export const MemoListPane = ({ // Keep enough room for the full action list. Radix can still adjust the // final position, but this prevents the initial placement from starting // below the viewport on short or zoomed desktop viewports. - const menuHeight = view === "trash" ? 216 : 356; + const menuHeight = view === "trash" ? 216 : 392; const x = Math.min(clientX, Math.max(12, window.innerWidth - menuWidth - 12)); const y = Math.min(clientY, Math.max(12, window.innerHeight - menuHeight - 12)); @@ -1634,6 +1649,15 @@ export const MemoListPane = ({ {t(isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync" : "sharing.action")} + {!memoContextMenu.memo.diagramKind && !memoContextMenu.memo.structuredTable && !memoContextMenu.memo.infographic && ( + void handleCopyContextMemoToWeChat()} + > + + {t("editor.copyToWeChat")} + + )} requestContextDocumentAction("export-markdown")} @@ -1695,6 +1719,12 @@ export const MemoListPane = ({ )} + {wechatCopyNotice && ( + + {t(wechatCopyNotice === "copied" ? "editor.copiedToWeChat" : "editor.copyToWeChatFailed")} + + )} + {selectionMode && ( 0 && !isDeleting} diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 0eb28646..d0e447c2 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -137,6 +137,7 @@ import { compressImageForUpload } from "@/lib/image-compression"; import { createScreenshotMemo, screenshotFileFromImportPayload, screenshotImportDedupeKey, screenshotImportGate } from "@/lib/screenshot-import"; import { createSharedFileMemo, sharedFileTitle } from "@/lib/shared-file-import"; import { createWeChatChatMemo } from "@/lib/wechat-chat-import"; +import { copyMarkdownToWeChat } from "@/lib/wechat-copy"; import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources"; import { findMatchingMemoResource } from "@/lib/staged-resource-repair"; @@ -3380,6 +3381,29 @@ export const WorkspaceApp = ({ printWindow, }); }} + onCopyMemoToWeChat={async (memoId) => { + const openTextNote = selectedMemo?.id === memoId + && !selectedDiagram + && !selectedTableNote + && !selectedInfographicNote; + if (openTextNote) { + memoDocumentActionIdRef.current += 1; + setMemoDocumentActionRequest({ + id: memoDocumentActionIdRef.current, + memoId, + action: "copy-wechat", + }); + return "editor"; + } + + try { + const { memo } = await repository.getMemo(memoId, memoView === "trash"); + await copyMarkdownToWeChat(memo.contentMarkdown); + return "copied"; + } catch { + return "error"; + } + }} onTogglePinMemo={handleToggleMemoPinned} onPinSelectedMemos={handlePinSelectedMemos} onExportSelectedMemos={handleExportSelectedMemos} diff --git a/apps/web/src/components/editor/editor-document-actions.test.mjs b/apps/web/src/components/editor/editor-document-actions.test.mjs index 342994bd..8714271e 100644 --- a/apps/web/src/components/editor/editor-document-actions.test.mjs +++ b/apps/web/src/components/editor/editor-document-actions.test.mjs @@ -70,6 +70,7 @@ describe("dispatchMemoDocumentAction", () => { saveAsTemplate: () => calls.push("template"), share: () => calls.push("share"), shareImage: () => calls.push("image"), + copyWeChat: () => calls.push("wechat"), }; dispatchMemoDocumentAction("share", handlers); @@ -78,7 +79,8 @@ describe("dispatchMemoDocumentAction", () => { dispatchMemoDocumentAction("export-pdf", handlers, null); dispatchMemoDocumentAction("share-image", handlers); dispatchMemoDocumentAction("save-as-template", handlers); + dispatchMemoDocumentAction("copy-wechat", handlers); - expect(calls).toEqual(["share", "markdown", "html", ["pdf", null], "image", "template"]); + expect(calls).toEqual(["share", "markdown", "html", ["pdf", null], "image", "template", "wechat"]); }); }); diff --git a/apps/web/src/components/editor/editor-document-actions.ts b/apps/web/src/components/editor/editor-document-actions.ts index 3a7a71bb..0fcc68a0 100644 --- a/apps/web/src/components/editor/editor-document-actions.ts +++ b/apps/web/src/components/editor/editor-document-actions.ts @@ -47,6 +47,7 @@ export const dispatchMemoDocumentAction = ( saveAsTemplate: () => void; share: () => void; shareImage: () => void; + copyWeChat: () => void; }, printWindow?: Window | null, ) => { @@ -69,5 +70,8 @@ export const dispatchMemoDocumentAction = ( case "save-as-template": handlers.saveAsTemplate(); break; + case "copy-wechat": + handlers.copyWeChat(); + break; } }; diff --git a/apps/web/src/components/editor/useEditorDocumentActions.ts b/apps/web/src/components/editor/useEditorDocumentActions.ts index 6b5027d7..e4007c32 100644 --- a/apps/web/src/components/editor/useEditorDocumentActions.ts +++ b/apps/web/src/components/editor/useEditorDocumentActions.ts @@ -237,6 +237,7 @@ export const useEditorDocumentActions = ({ if (canShareMemo) setShareOpen(true); }, shareImage: handleOpenImageShare, + copyWeChat: () => void handleCopyToWeChat(), }, documentActionRequest.printWindow, ); @@ -244,6 +245,7 @@ export const useEditorDocumentActions = ({ canShareMemo, documentActionRequest, editor, + handleCopyToWeChat, handleExportHtml, handleExportMarkdown, handleExportPdf, diff --git a/apps/web/src/lib/app-helpers.ts b/apps/web/src/lib/app-helpers.ts index c524ccac..153bfab4 100644 --- a/apps/web/src/lib/app-helpers.ts +++ b/apps/web/src/lib/app-helpers.ts @@ -42,7 +42,9 @@ export type MemoDocumentAction = | "export-html" | "export-pdf" | "share-image" - | "save-as-template"; + | "save-as-template" + | "copy-wechat"; +export type MemoWeChatCopyResult = "editor" | "copied" | "error"; export type MemoDocumentActionRequest = { id: number; memoId: string; diff --git a/tests/memo-list-wechat-copy.test.ts b/tests/memo-list-wechat-copy.test.ts new file mode 100644 index 00000000..76c9d50d --- /dev/null +++ b/tests/memo-list-wechat-copy.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +const readSource = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); + +const memoListSource = readSource("../apps/web/src/components/MemoListPane.tsx"); +const workspaceSource = readSource("../apps/web/src/components/WorkspaceApp.tsx"); +const editorActionsSource = readSource("../apps/web/src/components/editor/useEditorDocumentActions.ts"); + +describe("memo list WeChat copy", () => { + test("adds the publish copy action to text-note card menus", () => { + expect(memoListSource).toContain('t("editor.copyToWeChat")'); + expect(memoListSource).toContain("!memoContextMenu.memo.diagramKind && !memoContextMenu.memo.structuredTable && !memoContextMenu.memo.infographic"); + expect(memoListSource).toContain("void handleCopyContextMemoToWeChat()"); + expect(memoListSource).toContain('wechatCopyNotice === "copied" ? "editor.copiedToWeChat" : "editor.copyToWeChatFailed"'); + }); + + test("copies the open text note through the editor and other notes from saved markdown", () => { + expect(workspaceSource).toContain('action: "copy-wechat"'); + expect(workspaceSource).toContain("copyMarkdownToWeChat(memo.contentMarkdown)"); + expect(workspaceSource).toContain("return \"editor\""); + expect(editorActionsSource).toContain("copyWeChat: () => void handleCopyToWeChat()"); + }); +});