diff --git a/apps/web/src/components/ClipboardCopyNotice.tsx b/apps/web/src/components/ClipboardCopyNotice.tsx
new file mode 100644
index 00000000..ad1ed565
--- /dev/null
+++ b/apps/web/src/components/ClipboardCopyNotice.tsx
@@ -0,0 +1,26 @@
+import type { ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { cn } from "@/lib/utils";
+
+export const ClipboardCopyNotice = ({
+ children,
+ status,
+}: {
+ children: ReactNode;
+ status: "copied" | "error";
+}) => {
+ if (typeof document === "undefined") return null;
+
+ return createPortal(
+
+ {children}
+
,
+ document.body,
+ );
+};
diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx
index a41ebed7..edac838f 100644
--- a/apps/web/src/components/EditorPane.tsx
+++ b/apps/web/src/components/EditorPane.tsx
@@ -42,6 +42,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { GitHubRepositoryLink } from "@/components/GitHubRepositoryLink";
+import { ClipboardCopyNotice } from "@/components/ClipboardCopyNotice";
import { Input } from "@/components/ui/input";
import {
Command,
@@ -4273,15 +4274,9 @@ const RichEditorPane = ({
)}
{memoIdCopyNotice && (
-
+
{t(memoIdCopyNotice.status === "copied" ? "editor.noteIdCopied" : "editor.noteIdCopyFailed", { id: memoIdCopyNotice.id })}
-
+
)}
{false && useMobilePlainTextEditor && (
diff --git a/apps/web/src/components/MemoListPane.tsx b/apps/web/src/components/MemoListPane.tsx
index 787c4620..bd3d8557 100644
--- a/apps/web/src/components/MemoListPane.tsx
+++ b/apps/web/src/components/MemoListPane.tsx
@@ -58,6 +58,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { MemoCard } from "./MemoCard";
+import { ClipboardCopyNotice } from "./ClipboardCopyNotice";
import { cn } from "@/lib/utils";
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import type { Notebook, MemoSummary } from "@edgeever/shared";
@@ -1608,15 +1609,9 @@ export const MemoListPane = ({
) : null}
{memoIdCopyNotice && (
-
+
{t(memoIdCopyNotice.status === "copied" ? "editor.noteIdCopied" : "editor.noteIdCopyFailed", { id: memoIdCopyNotice.id })}
-
+
)}
{selectionMode && (
diff --git a/apps/web/src/components/TemplatesPane.tsx b/apps/web/src/components/TemplatesPane.tsx
index 2c7e02b4..adfa15a3 100644
--- a/apps/web/src/components/TemplatesPane.tsx
+++ b/apps/web/src/components/TemplatesPane.tsx
@@ -22,6 +22,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import { copyTextToClipboard } from "@/lib/clipboard";
+import { ClipboardCopyNotice } from "@/components/ClipboardCopyNotice";
import type { MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
const TemplateIconAction = ({
@@ -543,17 +544,12 @@ export const TemplatesPane = ({
)}
{templateIdCopyNotice && (
-
+
{t(
templateIdCopyNotice.status === "copied" ? "templates.idCopied" : "templates.idCopyFailed",
{ id: templateIdCopyNotice.id },
)}
-
+
)}
diff --git a/tests/note-id-copy.test.ts b/tests/note-id-copy.test.ts
index f6a4cd86..fc520497 100644
--- a/tests/note-id-copy.test.ts
+++ b/tests/note-id-copy.test.ts
@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
const readSource = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const webEditorSource = readSource("../apps/web/src/components/EditorPane.tsx");
+const webMemoListSource = readSource("../apps/web/src/components/MemoListPane.tsx");
+const webCopyNoticeSource = readSource("../apps/web/src/components/ClipboardCopyNotice.tsx");
const androidDetailSource = readSource("../apps/mobile/src/screens/WorkspaceMemoDetail.tsx");
const iosDetailSource = readSource("../apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift");
@@ -13,6 +15,13 @@ describe("copy current note ID", () => {
expect(webEditorSource).toContain('"editor.copyNoteId"');
});
+ test("renders copy feedback at the page root so panes cannot clip it", () => {
+ expect(webMemoListSource).toContain(" {
expect(androidDetailSource).toContain("Clipboard.setStringAsync(memo.id)");
expect(androidDetailSource).toContain('label={canCopyMemoId ? "复制笔记 ID"');
diff --git a/tests/template-id-copy.test.ts b/tests/template-id-copy.test.ts
index eaf1e758..5f3794a6 100644
--- a/tests/template-id-copy.test.ts
+++ b/tests/template-id-copy.test.ts
@@ -11,5 +11,6 @@ describe("copy note template ID", () => {
expect(templatesPaneSource).toContain("copyTextToClipboard(template.id)");
expect(templatesPaneSource).toContain('label={t("templates.copyId")}');
expect(templatesPaneSource).toContain('"templates.idCopied"');
+ expect(templatesPaneSource).toContain("