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.
This commit is contained in:
tianma-if committed 2026-09-30 20:12:49 +08:00
1 parent fbb41aee9d
commit 65ef837f45
7 files changed
+91 -3

No files matched your search

+31 -1
View File
@@ -51,6 +51,7 @@ import {
Pencil, Pencil,
Copy, Copy,
} from "lucide-react"; } from "lucide-react";
import { WeChatIcon } from "./WeChatIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -75,6 +76,7 @@ import type {
MemoListDensity, MemoListDensity,
MemoContextMenuState, MemoContextMenuState,
MemoDocumentAction, MemoDocumentAction,
MemoWeChatCopyResult,
NotebookMoveOption, NotebookMoveOption,
} from "@/lib/app-helpers"; } from "@/lib/app-helpers";
import { contentEnterMotion, paneEnterMotion } from "@/lib/motion"; import { contentEnterMotion, paneEnterMotion } from "@/lib/motion";
@@ -392,6 +394,7 @@ export const MemoListPane = ({
onTogglePinMemo, onTogglePinMemo,
onMoveMemo, onMoveMemo,
onRequestDocumentAction, onRequestDocumentAction,
onCopyMemoToWeChat,
onMoveSelectedMemos, onMoveSelectedMemos,
onPinSelectedMemos, onPinSelectedMemos,
onExportSelectedMemos, onExportSelectedMemos,
@@ -471,6 +474,7 @@ export const MemoListPane = ({
onTogglePinMemo: (memo: MemoSummary) => void; onTogglePinMemo: (memo: MemoSummary) => void;
onMoveMemo: (memoId: string, notebookId: string) => void; onMoveMemo: (memoId: string, notebookId: string) => void;
onRequestDocumentAction: (memoId: string, action: MemoDocumentAction, printWindow?: Window | null) => void; onRequestDocumentAction: (memoId: string, action: MemoDocumentAction, printWindow?: Window | null) => void;
onCopyMemoToWeChat: (memoId: string) => Promise<MemoWeChatCopyResult>;
onMoveSelectedMemos: (notebookId: string) => void; onMoveSelectedMemos: (notebookId: string) => void;
onPinSelectedMemos: (pinned: boolean) => void; onPinSelectedMemos: (pinned: boolean) => void;
onExportSelectedMemos: () => void; onExportSelectedMemos: () => void;
@@ -515,6 +519,7 @@ export const MemoListPane = ({
const [lastSelectedMemoId, setLastSelectedMemoId] = useState<string | null>(null); const [lastSelectedMemoId, setLastSelectedMemoId] = useState<string | null>(null);
const [moveTargetNotebookId, setMoveTargetNotebookId] = useState(""); const [moveTargetNotebookId, setMoveTargetNotebookId] = useState("");
const [memoIdCopyNotice, setMemoIdCopyNotice] = useState<{ status: "copied" | "error"; id: string } | null>(null); 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 [fileDragActive, setFileDragActive] = useState(false);
const filterOptions = useMemo(() => getMemoFilterOptions(t), [t]); const filterOptions = useMemo(() => getMemoFilterOptions(t), [t]);
@@ -655,6 +660,16 @@ export const MemoListPane = ({
window.setTimeout(() => setMemoIdCopyNotice(null), copied ? 2200 : 3000); 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(() => { useEffect(() => {
const nextTargetId = resolveSelectionMoveTargetNotebookId( const nextTargetId = resolveSelectionMoveTargetNotebookId(
moveTargetNotebookId, moveTargetNotebookId,
@@ -875,7 +890,7 @@ export const MemoListPane = ({
// Keep enough room for the full action list. Radix can still adjust the // Keep enough room for the full action list. Radix can still adjust the
// final position, but this prevents the initial placement from starting // final position, but this prevents the initial placement from starting
// below the viewport on short or zoomed desktop viewports. // 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 x = Math.min(clientX, Math.max(12, window.innerWidth - menuWidth - 12));
const y = Math.min(clientY, Math.max(12, window.innerHeight - menuHeight - 12)); const y = Math.min(clientY, Math.max(12, window.innerHeight - menuHeight - 12));
@@ -1634,6 +1649,15 @@ export const MemoListPane = ({
<Share2 className="h-4 w-4 text-slate-500" /> <Share2 className="h-4 w-4 text-slate-500" />
{t(isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync" : "sharing.action")} {t(isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync" : "sharing.action")}
</DropdownMenuItem> </DropdownMenuItem>
{!memoContextMenu.memo.diagramKind && !memoContextMenu.memo.structuredTable && !memoContextMenu.memo.infographic && (
<DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={() => void handleCopyContextMemoToWeChat()}
>
<WeChatIcon className="h-4 w-4 text-slate-500" />
{t("editor.copyToWeChat")}
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs text-slate-700 hover:bg-slate-50 cursor-pointer outline-none" className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={() => requestContextDocumentAction("export-markdown")} onClick={() => requestContextDocumentAction("export-markdown")}
@@ -1695,6 +1719,12 @@ export const MemoListPane = ({
</ClipboardCopyNotice> </ClipboardCopyNotice>
)} )}
{wechatCopyNotice && (
<ClipboardCopyNotice status={wechatCopyNotice}>
{t(wechatCopyNotice === "copied" ? "editor.copiedToWeChat" : "editor.copyToWeChatFailed")}
</ClipboardCopyNotice>
)}
{selectionMode && ( {selectionMode && (
<MobileSelectionActionBar <MobileSelectionActionBar
canDelete={selectedMemoIds.size > 0 && !isDeleting} canDelete={selectedMemoIds.size > 0 && !isDeleting}
+24
View File
@@ -137,6 +137,7 @@ import { compressImageForUpload } from "@/lib/image-compression";
import { createScreenshotMemo, screenshotFileFromImportPayload, screenshotImportDedupeKey, screenshotImportGate } from "@/lib/screenshot-import"; import { createScreenshotMemo, screenshotFileFromImportPayload, screenshotImportDedupeKey, screenshotImportGate } from "@/lib/screenshot-import";
import { createSharedFileMemo, sharedFileTitle } from "@/lib/shared-file-import"; import { createSharedFileMemo, sharedFileTitle } from "@/lib/shared-file-import";
import { createWeChatChatMemo } from "@/lib/wechat-chat-import"; import { createWeChatChatMemo } from "@/lib/wechat-chat-import";
import { copyMarkdownToWeChat } from "@/lib/wechat-copy";
import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources"; import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources";
import { findMatchingMemoResource } from "@/lib/staged-resource-repair"; import { findMatchingMemoResource } from "@/lib/staged-resource-repair";
@@ -3380,6 +3381,29 @@ export const WorkspaceApp = ({
printWindow, 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} onTogglePinMemo={handleToggleMemoPinned}
onPinSelectedMemos={handlePinSelectedMemos} onPinSelectedMemos={handlePinSelectedMemos}
onExportSelectedMemos={handleExportSelectedMemos} onExportSelectedMemos={handleExportSelectedMemos}
@@ -70,6 +70,7 @@ describe("dispatchMemoDocumentAction", () => {
saveAsTemplate: () => calls.push("template"), saveAsTemplate: () => calls.push("template"),
share: () => calls.push("share"), share: () => calls.push("share"),
shareImage: () => calls.push("image"), shareImage: () => calls.push("image"),
copyWeChat: () => calls.push("wechat"),
}; };
dispatchMemoDocumentAction("share", handlers); dispatchMemoDocumentAction("share", handlers);
@@ -78,7 +79,8 @@ describe("dispatchMemoDocumentAction", () => {
dispatchMemoDocumentAction("export-pdf", handlers, null); dispatchMemoDocumentAction("export-pdf", handlers, null);
dispatchMemoDocumentAction("share-image", handlers); dispatchMemoDocumentAction("share-image", handlers);
dispatchMemoDocumentAction("save-as-template", 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"]);
}); });
}); });
@@ -47,6 +47,7 @@ export const dispatchMemoDocumentAction = (
saveAsTemplate: () => void; saveAsTemplate: () => void;
share: () => void; share: () => void;
shareImage: () => void; shareImage: () => void;
copyWeChat: () => void;
}, },
printWindow?: Window | null, printWindow?: Window | null,
) => { ) => {
@@ -69,5 +70,8 @@ export const dispatchMemoDocumentAction = (
case "save-as-template": case "save-as-template":
handlers.saveAsTemplate(); handlers.saveAsTemplate();
break; break;
case "copy-wechat":
handlers.copyWeChat();
break;
} }
}; };
@@ -237,6 +237,7 @@ export const useEditorDocumentActions = ({
if (canShareMemo) setShareOpen(true); if (canShareMemo) setShareOpen(true);
}, },
shareImage: handleOpenImageShare, shareImage: handleOpenImageShare,
copyWeChat: () => void handleCopyToWeChat(),
}, },
documentActionRequest.printWindow, documentActionRequest.printWindow,
); );
@@ -244,6 +245,7 @@ export const useEditorDocumentActions = ({
canShareMemo, canShareMemo,
documentActionRequest, documentActionRequest,
editor, editor,
handleCopyToWeChat,
handleExportHtml, handleExportHtml,
handleExportMarkdown, handleExportMarkdown,
handleExportPdf, handleExportPdf,
+3 -1
View File
@@ -42,7 +42,9 @@ export type MemoDocumentAction =
| "export-html" | "export-html"
| "export-pdf" | "export-pdf"
| "share-image" | "share-image"
| "save-as-template"; | "save-as-template"
| "copy-wechat";
export type MemoWeChatCopyResult = "editor" | "copied" | "error";
export type MemoDocumentActionRequest = { export type MemoDocumentActionRequest = {
id: number; id: number;
memoId: string; memoId: string;
+24
View File
@@ -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()");
});
});