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:
1 parent
fbb41aee9d
commit
65ef837f45
7 files changed
+91
-3
No files matched your search
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user