Skip to content

Commit 629b1a0

Browse files
committed
feat(notes): REQ-317 选区右键菜单纯逻辑与共享自绘菜单组件
1 parent b369f8e commit 629b1a0

5 files changed

Lines changed: 652 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* SelectionActionMenu — 笔记正文「选中文字右键」共享自绘菜单(REQ-317,批 8)。
3+
*
4+
* @ai-context: 阅读态(NoteReadingView)与编辑态(RichEditorView/CodeMirror)
5+
* 两宿主共用的应用内菜单——原生菜单已被 BrowserChrome 全局抑制,
6+
* 本组件即其替代:**文本快照(text)在菜单打开时由宿主捕获传入**
7+
* (Why:菜单内点击会动焦点/选区,动作不得依赖“点击瞬间的 DOM
8+
* 选区仍存活”;编辑态快照来自 CM state,阅读态来自 window
9+
* Selection)。复制在菜单内自执行并内联展示结果(行菜单范式:
10+
* 复制后保持打开、状态行留痕);其余动作经 onAction 委托宿主
11+
* (先 onClose 再执行——既有行菜单范式)。
12+
* @ai-context: 交互契约同既有自绘菜单:透明背板(点击/右键即关)、ESC 关闭
13+
* (**capture 层监听 + stopPropagation**——编辑态下 NotesPage 有
14+
* 全局 ESC=退出编辑的 window 监听(挂载更早、先于本组件 bubble
15+
* 监听),capture 先拦截可让 ESC 只关菜单不误退编辑)、坐标钳制、
16+
* 滚动/失焦/缩放收起。背板与面板 z 60/61 同既有行菜单。
17+
*/
18+
import { useEffect, useRef, useState } from "react";
19+
import {
20+
buildSelectionMenuItems,
21+
clampMenuXY,
22+
type NoteSelectionMode,
23+
type SelectionActionId,
24+
} from "../../utils/noteSelectionMenu";
25+
import { writeClipboardText } from "../../utils/clipboardWrite";
26+
27+
interface Props {
28+
x: number;
29+
y: number;
30+
mode: NoteSelectionMode;
31+
/** 菜单打开瞬间的选区文本快照(见文件头 Why) */
32+
text: string;
33+
onClose: () => void;
34+
/** 非复制动作委托宿主(先关菜单后执行;selectAll/addTask 亦走此) */
35+
onAction: (id: Exclude<SelectionActionId, "copy">) => void;
36+
}
37+
38+
const MENU_W = 224;
39+
/** 行高/头/状态行余量的高度估算(钳制用;菜单自身不溢出视口) */
40+
const ROW_H = 30;
41+
const HEAD_H = 34;
42+
43+
const ITEM: React.CSSProperties = {
44+
display: "flex",
45+
alignItems: "center",
46+
gap: 8,
47+
width: "100%",
48+
padding: "5px 10px",
49+
border: "none",
50+
background: "none",
51+
borderRadius: 6,
52+
fontSize: 12.5,
53+
cursor: "pointer",
54+
textAlign: "left",
55+
};
56+
57+
export default function SelectionActionMenu({ x, y, mode, text, onClose, onAction }: Props) {
58+
const [status, setStatus] = useState("");
59+
const items = buildSelectionMenuItems(mode, text.trim().length > 0);
60+
const statusShown = status.length > 0;
61+
// 复制的异步落定后组件可能已卸载(点背板关闭)——写标志防 setState on unmounted
62+
const aliveRef = useRef(true);
63+
useEffect(() => () => { aliveRef.current = false; }, []);
64+
65+
// ESC 关闭:capture 层先于 NotesPage 全局 ESC(退出编辑)监听生效并阻断冒泡
66+
// ——菜单开着时 ESC 只关菜单(见文件头);关闭后监听移除,ESC 恢复全局语义
67+
useEffect(() => {
68+
const onKey = (e: KeyboardEvent) => {
69+
if (e.key === "Escape") {
70+
e.stopPropagation();
71+
onClose();
72+
}
73+
};
74+
window.addEventListener("keydown", onKey, true);
75+
return () => window.removeEventListener("keydown", onKey, true);
76+
}, [onClose]);
77+
78+
// 滚动收起(capture:正文容器自身的 scroll 也会到 window;菜单自身滚动除外)
79+
const menuRef = useRef<HTMLDivElement | null>(null);
80+
useEffect(() => {
81+
const close = () => onClose();
82+
const onScroll = (e: Event) => {
83+
if (menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) return;
84+
close();
85+
};
86+
const onBlurOrResize = () => onClose();
87+
window.addEventListener("scroll", onScroll, true);
88+
window.addEventListener("blur", onBlurOrResize);
89+
window.addEventListener("resize", onBlurOrResize);
90+
return () => {
91+
window.removeEventListener("scroll", onScroll, true);
92+
window.removeEventListener("blur", onBlurOrResize);
93+
window.removeEventListener("resize", onBlurOrResize);
94+
};
95+
}, [onClose]);
96+
97+
const menuH = HEAD_H + items.length * ROW_H + (statusShown ? 26 : 8);
98+
const pos = clampMenuXY(x, y, MENU_W, menuH, window.innerWidth, window.innerHeight);
99+
100+
const copy = () => {
101+
void writeClipboardText(text).then((ok) => {
102+
if (!aliveRef.current) return;
103+
setStatus(ok ? "已复制选中文本" : "复制失败(可用 Ctrl+C)");
104+
});
105+
};
106+
107+
return (
108+
<>
109+
{/* 透明背板:点击/右键收起(覆盖全屏拦截) */}
110+
<div
111+
onClick={onClose}
112+
onContextMenu={(e) => { e.preventDefault(); onClose(); }}
113+
style={{ position: "fixed", inset: 0, zIndex: 60, background: "transparent" }}
114+
/>
115+
<div
116+
ref={menuRef}
117+
role="menu"
118+
data-testid="note-sel-menu"
119+
data-app-menu=""
120+
onContextMenu={(e) => e.preventDefault()}
121+
// preventDefault 防焦点窃取:mousedown 默认会聚焦按钮使 CM/正文失焦
122+
// (复制后菜单保持打开留状态行——编辑态焦点须留在编辑器)
123+
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
124+
style={{
125+
position: "fixed",
126+
left: pos.x,
127+
top: pos.y,
128+
zIndex: 61,
129+
width: MENU_W,
130+
background: "#fff",
131+
border: "1px solid #e5e7eb",
132+
borderRadius: 8,
133+
boxShadow: "0 8px 24px rgba(0,0,0,0.14)",
134+
padding: 4,
135+
}}
136+
>
137+
{/* 快照预览(行菜单标题位同构;溢出省略 + title 全文) */}
138+
<div
139+
style={{
140+
fontSize: 11,
141+
color: "#6b7280",
142+
padding: "2px 10px 4px",
143+
whiteSpace: "nowrap",
144+
overflow: "hidden",
145+
textOverflow: "ellipsis",
146+
}}
147+
title={text}
148+
>
149+
{text}
150+
</div>
151+
{items.map((item) => (
152+
<div key={item.id}>
153+
{item.dividerBefore && <div style={{ height: 1, background: "#f3f4f6", margin: "3px 6px" }} />}
154+
<button
155+
data-testid={`note-sel-${item.id}`}
156+
disabled={!item.enabled}
157+
onClick={() => {
158+
if (!item.enabled) return;
159+
if (item.id === "copy") { copy(); return; }
160+
onClose();
161+
onAction(item.id);
162+
}}
163+
style={{
164+
...ITEM,
165+
color: item.enabled ? "#374151" : "#d1d5db",
166+
cursor: item.enabled ? "pointer" : "default",
167+
}}
168+
>
169+
<span style={{ width: 20, fontSize: 12, textAlign: "center" }}>{item.icon}</span>
170+
{item.label}
171+
</button>
172+
</div>
173+
))}
174+
{statusShown && (
175+
<div
176+
data-testid="note-sel-copy-status"
177+
style={{
178+
fontSize: 11,
179+
color: status.startsWith("已") ? "#047857" : "#dc2626",
180+
padding: "2px 10px 2px",
181+
}}
182+
>
183+
{status}
184+
</div>
185+
)}
186+
</div>
187+
</>
188+
);
189+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* clipboardWrite.test.ts — 剪贴板写入 util(REQ-317)。
4+
* 覆盖:主路径(clipboard API)成功;主路径拒绝 → 兜底 execCommand 成功;
5+
* 双路径皆不可用 → false(不抛异常);navigator 缺失环境守卫。
6+
*/
7+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8+
import { writeClipboardText } from "./clipboardWrite";
9+
10+
const originalClipboard = navigator.clipboard;
11+
12+
/** jsdom 未实现 document.execCommand——按需以 stub 属性注入(afterEach 清理) */
13+
function stubExecCommand(impl: () => boolean) {
14+
Object.defineProperty(document, "execCommand", { configurable: true, value: impl });
15+
}
16+
17+
beforeEach(() => {
18+
vi.restoreAllMocks();
19+
});
20+
21+
afterEach(() => {
22+
Object.defineProperty(document, "execCommand", { configurable: true, value: undefined });
23+
Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
24+
});
25+
26+
describe("writeClipboardText", () => {
27+
it("clipboard API 可用 → 写入成功返回 true", async () => {
28+
const writeText = vi.fn().mockResolvedValue(undefined);
29+
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
30+
await expect(writeClipboardText("片段")).resolves.toBe(true);
31+
expect(writeText).toHaveBeenCalledWith("片段");
32+
});
33+
34+
it("clipboard API 拒绝 → 兜底 execCommand 路径", async () => {
35+
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
36+
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
37+
const exec = vi.fn().mockReturnValue(true);
38+
stubExecCommand(exec);
39+
await expect(writeClipboardText("片段")).resolves.toBe(true);
40+
expect(exec).toHaveBeenCalledWith("copy");
41+
// 兜底临时 textarea 已清理(不留 DOM 残渣)
42+
expect(document.querySelectorAll("textarea")).toHaveLength(0);
43+
});
44+
45+
it("双路径不可用 → false(不抛异常)", async () => {
46+
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
47+
stubExecCommand(() => false);
48+
await expect(writeClipboardText("x")).resolves.toBe(false);
49+
});
50+
51+
it("execCommand 抛异常(极端宿主)→ false", async () => {
52+
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
53+
stubExecCommand(() => {
54+
throw new Error("no exec");
55+
});
56+
await expect(writeClipboardText("x")).resolves.toBe(false);
57+
});
58+
});

‎app/src/utils/clipboardWrite.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* clipboardWrite — 应用内统一剪贴板写入(REQ-317 选区菜单复制动作)。
3+
*
4+
* @ai-context: WebView2 剪贴板 API 在安全上下文中可用,但权限拒绝/降级宿主
5+
* 需兜底——execCommand("copy") 经隐藏 textarea 路径(BrowserChrome/
6+
* 行菜单既有模式)。返回布尔成败,调用方按需展示反馈;
7+
* Ctrl+C 键盘主路径始终不受影响。
8+
*/
9+
export async function writeClipboardText(text: string): Promise<boolean> {
10+
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
11+
try {
12+
await navigator.clipboard.writeText(text);
13+
return true;
14+
} catch {
15+
// 权限拒绝/不可用 → 落兜底路径(不吞掉反馈权)
16+
}
17+
}
18+
try {
19+
if (typeof document === "undefined" || typeof document.execCommand !== "function") return false;
20+
const ta = document.createElement("textarea");
21+
ta.value = text;
22+
ta.setAttribute("readonly", "");
23+
// 视觉隐藏但保持可选中(opacity/position 方案,避免 display:none 不可选)
24+
ta.style.position = "fixed";
25+
ta.style.opacity = "0";
26+
document.body.appendChild(ta);
27+
ta.select();
28+
let ok = false;
29+
try {
30+
ok = document.execCommand("copy");
31+
} finally {
32+
document.body.removeChild(ta);
33+
}
34+
return ok;
35+
} catch {
36+
return false;
37+
}
38+
}

0 commit comments

Comments
 (0)