Skip to content

Commit 2ab400c

Browse files
committed
fix(notes): 选区菜单失效清理/快照锚点/复制与弹层加固(批8 审查)
1 parent 35f1d18 commit 2ab400c

12 files changed

Lines changed: 303 additions & 19 deletions

‎app/src/components/NoteReadingView.test.tsx‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,48 @@ describe("阅读态正文选区右键菜单(批 8 REQ-317)", () => {
116116
fireEvent.click(screen.getByTestId("note-sel-toQuestion"));
117117
expect(onSelectionAction).toHaveBeenCalledWith("toQuestion", "选中段落文本");
118118
});
119+
120+
it("进编辑再退出(Ctrl+E→ESC 往返):旧菜单不复活——状态随 editing 失效(审查 P2-12)", () => {
121+
const { rerender } = renderSel({});
122+
const openMenu = () => {
123+
const body = document.querySelector("[data-note-read-body]");
124+
const para = body!.querySelector("p");
125+
const selStub = {
126+
isCollapsed: false,
127+
rangeCount: 1,
128+
anchorNode: para!.firstChild,
129+
focusNode: para!.firstChild,
130+
toString: () => "旧快照文本",
131+
};
132+
vi.spyOn(window, "getSelection").mockReturnValue(selStub as unknown as Selection);
133+
fireEvent.contextMenu(para!, { clientX: 80, clientY: 80 });
134+
};
135+
openMenu();
136+
expect(screen.getByTestId("note-sel-menu")).toBeTruthy();
137+
// Ctrl+E 进编辑(编辑态替换正文区)→ 退出编辑回阅读
138+
rerender(<NoteReadingView note={baseNote()} editing={true} onEdit={noop} onPinToggle={noop} onDelete={noop} onTagClick={noop} onOpenSession={noop} onTaskToggle={noop} onImageOpen={noop} />);
139+
expect(screen.queryByTestId("note-sel-menu")).toBeNull();
140+
rerender(<NoteReadingView note={baseNote()} editing={false} onEdit={noop} onPinToggle={noop} onDelete={noop} onTagClick={noop} onOpenSession={noop} onTaskToggle={noop} onImageOpen={noop} />);
141+
// 旧菜单(旧快照)不得随 `!editing` 门控复活
142+
expect(screen.queryByTestId("note-sel-menu")).toBeNull();
143+
});
144+
145+
it("切笔记后旧菜单不复活(防旧快照文本 + 新 note 上下文错配——审查 P2-12)", () => {
146+
const second = { ...baseNote(), id: 2, title: "另一篇", content: "别的正文" };
147+
const { rerender } = renderSel({});
148+
const body = document.querySelector("[data-note-read-body]");
149+
const para = body!.querySelector("p");
150+
const selStub = {
151+
isCollapsed: false,
152+
rangeCount: 1,
153+
anchorNode: para!.firstChild,
154+
focusNode: para!.firstChild,
155+
toString: () => "甲笔记的选中文本",
156+
};
157+
vi.spyOn(window, "getSelection").mockReturnValue(selStub as unknown as Selection);
158+
fireEvent.contextMenu(para!, { clientX: 80, clientY: 80 });
159+
expect(screen.getByTestId("note-sel-menu")).toBeTruthy();
160+
rerender(<NoteReadingView note={second} editing={false} onEdit={noop} onPinToggle={noop} onDelete={noop} onTagClick={noop} onOpenSession={noop} onTaskToggle={noop} onImageOpen={noop} />);
161+
expect(screen.queryByTestId("note-sel-menu")).toBeNull();
162+
});
119163
});

‎app/src/components/NoteReadingView.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ export default function NoteReadingView({
7474
// 用户关闭搜索/离开笔记再普通打开同一笔记时,旧命中词不得自动重现
7575
const injectedRef = useRef<{ noteId: number; key: number } | null>(null);
7676

77+
// 批 8 审查 P2-12:菜单快照属"打开瞬间"——切笔记/进出编辑后旧快照作废
78+
// (只靠 `!editing && selMenu` 渲染门控会让旧菜单在退出编辑/切笔记后复活,
79+
// "转问题"会以旧文本 + 新 note 上下文错配)。状态清理而非仅渲染隐藏:
80+
// 复活路径(Ctrl+E 进编辑→ESC 退出)与切笔记路径都被本条 effect 覆盖。
81+
useEffect(() => { setSelMenu(null); }, [note.id, editing]);
82+
7783
// v0.19.1:外部命中词搜索(引用跳转自动激活——key 递增允许同词重触发;
7884
// 编辑态不注入(审查 M2——高亮只属于阅读视图,且搜索框 autoFocus 不得抢
7985
// 编辑器焦点);编辑退出后同一请求可再次注入)

‎app/src/components/RichEditorView.test.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,34 @@ describe("RichEditorView 选区右键菜单(批 8 REQ-317)", () => {
124124
fireEvent.click(await screen.findByTestId("note-sel-toQuestion"));
125125
expect(onSelectionAction).toHaveBeenCalledWith("toQuestion", "第一行\n第二行");
126126
});
127+
128+
it("菜单打开后光标移走 → 加入行动仍插在快照 to(不用点击瞬间光标——审查 P2-13)", async () => {
129+
render(<RichEditorView note={baseNote} onCancel={vi.fn()} />);
130+
await waitFor(() => expect(cmContent().textContent).toContain("第一行"));
131+
// Ctrl+A 全选 → 右键开菜单(快照 to=doc 末 7)
132+
fireEvent.keyDown(cmContent(), { key: "a", ctrlKey: true });
133+
fireEvent.contextMenu(cmContent(), { clientX: 120, clientY: 120 });
134+
expect(await screen.findByTestId("note-sel-menu")).toBeTruthy();
135+
// 菜单开着时把光标移到文首(连按 ←——任何一次都会离开末位,快照 to 不变)
136+
for (let i = 0; i < 20; i += 1) fireEvent.keyDown(cmContent(), { key: "ArrowLeft" });
137+
fireEvent.click(screen.getByTestId("note-sel-addTask"));
138+
// 断言:插在快照 to(末行行尾),而非点击瞬间光标处(文首→首行尾)——
139+
// 若用点击瞬间光标,任务行会插进「第一行」之后(textContent 出现行间任务)
140+
await waitFor(() => {
141+
expect(cmContent().textContent).toBe("第一行第二行- [ ] 第一行 第二行");
142+
});
143+
});
144+
145+
it("荧光笔色板开着时正文右键 → 选区菜单打开且色板随机关闭(互斥——审查 P3-2)", async () => {
146+
render(<RichEditorView note={baseNote} onCancel={vi.fn()} />);
147+
await waitFor(() => expect(cmContent().textContent).toContain("第一行"));
148+
fireEvent.click(screen.getByTestId("highlight-open"));
149+
expect(screen.getByTestId("highlight-pop")).toBeTruthy();
150+
fireEvent.keyDown(cmContent(), { key: "a", ctrlKey: true });
151+
fireEvent.contextMenu(cmContent(), { clientX: 120, clientY: 120 });
152+
expect(await screen.findByTestId("note-sel-menu")).toBeTruthy();
153+
expect(screen.queryByTestId("highlight-pop")).toBeNull();
154+
});
127155
});
128156

129157
describe("RichEditorView 草稿恢复层", () => {

‎app/src/components/RichEditorView.tsx‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,10 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
6868
const [draftPrompt, setDraftPrompt] = useState<{ title: string; content: string } | null>(null);
6969
// v0.16.1:荧光笔色板弹层开合(受控——选色/默认黄/点击外部关闭)
7070
const [highlightOpen, setHighlightOpen] = useState(false);
71-
// 批 8(REQ-317):编辑态选区右键菜单态(CM 选区文本快照——点击动作不再
72-
// 依赖“点击瞬间选区仍在”,快照语义与阅读态 SelectionActionMenu 一致)
73-
const [selMenu, setSelMenu] = useState<{ x: number; y: number; text: string } | null>(null);
71+
// 批 8(REQ-317):编辑态选区右键菜单态——CM 选区在菜单打开瞬间的**快照**
72+
// (text + to:文本与插入锚点同源同刻捕获,菜单内点击不依赖“点击瞬间的
73+
// CM 选区仍存活”——审查 P2-13:键盘移动光标后加入行动仍插在快照 to)
74+
const [selMenu, setSelMenu] = useState<{ x: number; y: number; text: string; to: number } | null>(null);
7475

7576
// refs 快照(卸载/定时器闭包取最新值,防 state 闭包过期——同 NoteEditView)
7677
const titleRef = useRef(title);
@@ -153,10 +154,14 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
153154
return;
154155
}
155156
if (action === "addTask") {
156-
// 加入行动:以「选区结束处所在行」为锚插入 `- [ ] <快照文本>` 独立任务行;
157-
// dispatch → 既有 onChange → 自动保存/草稿/任务索引重扫通道(不 bypass)
157+
// 加入行动:以「快照 to(菜单打开瞬间选区结束处)」所在行为锚插入
158+
// `- [ ] <快照文本>` 独立任务行;不用点击瞬间的 view.state.selection——
159+
// 菜单打开后键盘移动光标会改变主选区(快照 to 才是动作时有效的锚点,
160+
// 审查 P2-13);dispatch → 既有 onChange → 自动保存/草稿/任务索引重扫
161+
// 通道(不 bypass 保存)
158162
if (!view || !snapshotText) return;
159-
const plan = planTaskLineInsert(view.state.doc.toString(), view.state.selection.main.to, snapshotText);
163+
const snapshotTo = selMenu?.to ?? view.state.selection.main.to;
164+
const plan = planTaskLineInsert(view.state.doc.toString(), snapshotTo, snapshotText);
160165
if (!plan) return;
161166
view.dispatch({
162167
changes: { from: plan.from, insert: plan.insert },
@@ -195,7 +200,11 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
195200
if (!t.trim()) return false;
196201
e.preventDefault();
197202
e.stopPropagation(); // 自绘菜单范式:到 window 前截停(原生兜底同在)
198-
setSelMenu({ x: e.clientX, y: e.clientY, text: t });
203+
// 审查 P3-2:选区菜单打开时顺带关荧光笔色板(互斥——色板弹层 z30 在
204+
// 菜单背板 z60 之下,留开会形成视觉残留且只能靠点背板被动关闭)
205+
setHighlightOpen(false);
206+
// 快照含选区结束 offset(P2-13:加入行动锚点=打开瞬间,非点击瞬间)
207+
setSelMenu({ x: e.clientX, y: e.clientY, text: t, to: main.to });
199208
return true;
200209
},
201210
}),
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SelectionActionMenu.test.tsx — 共享选区菜单(批 8 REQ-317)补测。
4+
*
5+
* @ai-context: 覆盖审查 P3-4——坐标钳制一次性:位置在打开瞬间按「含状态行区」
6+
* 的最终高度计算(复制成功状态行出现不再重钳上跳);贴底场景下
7+
* style.top 从打开到状态行出现全程不变。invoke 无依赖(复制走
8+
* navigator.clipboard mock;菜单组件自身零后端调用)。
9+
*/
10+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
12+
import SelectionActionMenu from "./SelectionActionMenu";
13+
14+
const { writeText } = vi.hoisted(() => ({ writeText: vi.fn() }));
15+
16+
const originalInner = { w: window.innerWidth, h: window.innerHeight };
17+
const originalClipboard = navigator.clipboard;
18+
19+
const baseProps = {
20+
x: 500,
21+
y: 500,
22+
mode: "editing" as const,
23+
text: "选中文字快照",
24+
onClose: vi.fn(),
25+
onAction: vi.fn(),
26+
};
27+
28+
beforeEach(() => {
29+
writeText.mockReset();
30+
writeText.mockResolvedValue(undefined);
31+
// jsdom 无 navigator.clipboard——按可用注入(configurable 属性,afterEach 还原)
32+
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
33+
// 小视口:底部钳制必命中(贴底 y=500 > vh-h-4)
34+
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
35+
Object.defineProperty(window, "innerHeight", { configurable: true, value: 480 });
36+
});
37+
38+
afterEach(() => {
39+
cleanup();
40+
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInner.w });
41+
Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInner.h });
42+
Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
43+
});
44+
45+
describe("SelectionActionMenu 坐标钳制一次性(审查 P3-4)", () => {
46+
it("贴底场景:打开位置即终位——复制状态行出现后 top 不变(预留状态行区钳制)", async () => {
47+
// 编辑态 5 项:HEAD(34) + 5×ROW(30) + 状态行预留(26) + padding(8) = 218
48+
// 视口 480 → clamp 上限 = 480 - 218 - 4 = 258;y=500 贴底 → top=258
49+
const { baseElement } = render(<SelectionActionMenu {...baseProps} />);
50+
const menu = screen.getByTestId("note-sel-menu");
51+
expect(menu.style.top).toBe("258px");
52+
// 点击复制 → 状态行出现(菜单保持打开)——位置不得因高度重算而位移
53+
fireEvent.click(screen.getByTestId("note-sel-copy"));
54+
expect(await screen.findByTestId("note-sel-copy-status")).toBeTruthy();
55+
await waitFor(() => expect(writeText).toHaveBeenCalledWith("选中文字快照"));
56+
expect(menu.style.top).toBe("258px");
57+
expect(baseElement.querySelector('[data-testid="note-sel-copy-status"]')?.textContent).toContain("已复制");
58+
});
59+
60+
it("常规位置不受预留影响(上方不越界时按请求坐标定位)", () => {
61+
Object.defineProperty(window, "innerHeight", { configurable: true, value: 1000 });
62+
render(<SelectionActionMenu {...baseProps} x={120} y={200} />);
63+
const menu = screen.getByTestId("note-sel-menu");
64+
expect(menu.style.top).toBe("200px");
65+
expect(menu.style.left).toBe("120px");
66+
});
67+
});

‎app/src/components/note-selection/SelectionActionMenu.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ const MENU_W = 224;
3939
/** 行高/头/状态行余量的高度估算(钳制用;菜单自身不溢出视口) */
4040
const ROW_H = 30;
4141
const HEAD_H = 34;
42+
/** 状态行高度(复制成功后出现;`已复制…` 反馈行 ≈18px 字高 + 8px 纵向留白) */
43+
const STATUS_H = 26;
44+
/** 面板上下 padding 合计(style padding: 4) */
45+
const PANEL_PAD = 8;
4246

4347
const ITEM: React.CSSProperties = {
4448
display: "flex",
@@ -94,8 +98,13 @@ export default function SelectionActionMenu({ x, y, mode, text, onClose, onActio
9498
};
9599
}, [onClose]);
96100

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);
101+
// 坐标钳制(审查 P3-4 取舍):高度按「含状态行区」的最终形态**一次性**计算
102+
// ——Why 不随 statusShown 重算:贴底菜单在复制成功、状态行出现时会因高度
103+
// 增加(+18px 净增)被重钳上跳(位置抖动);预留后位置=终位,复制只增高
104+
// 不位移。代价:未触发状态行时菜单可比内容高出一截(多预留在上方空白),
105+
// 视觉可接受且换取“打开即终位”的稳定性。实际渲染高度仍随内容伸缩。
106+
const clampH = HEAD_H + items.length * ROW_H + STATUS_H + PANEL_PAD;
107+
const pos = clampMenuXY(x, y, MENU_W, clampH, window.innerWidth, window.innerHeight);
99108

100109
const copy = () => {
101110
void writeClipboardText(text).then((ok) => {

‎app/src/hooks/useNoteSelectionActions.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,27 @@ describe("useNoteSelectionActions", () => {
7373
expect(invokeMock).not.toHaveBeenCalled();
7474
expect(notify).not.toHaveBeenCalled();
7575
});
76+
77+
it("选中笔记变化 → 模型卡对话框随旧笔记快照关闭(旧 excerpt 不配新 noteId——审查 P2-12)", () => {
78+
const { result, rerender } = renderHook<ReturnType<typeof useNoteSelectionActions>, { noteId: number | null }>(
79+
({ noteId }) =>
80+
useNoteSelectionActions({ noteId, onChanged: vi.fn(), notify: vi.fn() }),
81+
{ initialProps: { noteId: 1 } },
82+
);
83+
act(() => result.current.handleSelectionAction("toModelCard", "甲笔记选中片段"));
84+
expect(result.current.modelDialog).toEqual({ excerpt: "甲笔记选中片段" });
85+
// 切笔记(列表选择另一篇——右栏 note 对象更换)
86+
rerender({ noteId: 2 });
87+
expect(result.current.modelDialog).toBeNull();
88+
// 关闭选中(selected→null:删除/切空态)同样关闭
89+
act(() => result.current.openModelCard());
90+
expect(result.current.modelDialog).toEqual({ excerpt: "" });
91+
rerender({ noteId: null });
92+
expect(result.current.modelDialog).toBeNull();
93+
// noteId 不变时打开/关闭照常(对话框生命周期不受 effect 干扰)
94+
act(() => result.current.openModelCard());
95+
expect(result.current.modelDialog).toEqual({ excerpt: "" });
96+
act(() => result.current.closeModelCard());
97+
expect(result.current.modelDialog).toBeNull();
98+
});
7699
});

‎app/src/hooks/useNoteSelectionActions.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* 预填**(≤200 单行化截断,组内 model 卡唯一生成链防双轨不变)。
1111
* 复制/全选/加入行动是宿主就地动作,不经本 hook。
1212
*/
13-
import { useCallback, useRef, useState } from "react";
13+
import { useCallback, useEffect, useRef, useState } from "react";
1414
import { invoke } from "@tauri-apps/api/core";
1515
import {
1616
SNIPPET_MAX,
@@ -33,6 +33,11 @@ export function useNoteSelectionActions({ noteId, onChanged, notify }: Options)
3333
// 转问题幂等闸(连点菜单项防双写——命令本身无副作用需求,纯 UI 防抖)
3434
const questionBusyRef = useRef(false);
3535

36+
// 批 8 审查 P2-12:对话框快照(excerpt + noteId 上下文)随选中笔记失效——
37+
// 切笔记后旧 excerpt 不得配新 noteId 打开(ModelCardDialogSlot 只做接线,
38+
// 关闭语义必须收敛在状态持有者);含 selected→null(关闭/删除笔记)路径
39+
useEffect(() => { setModelDialog(null); }, [noteId]);
40+
3641
/** header 🧠 模型卡入口(无选区上下文——excerpt 空预填保持原行为) */
3742
const openModelCard = useCallback(() => setModelDialog({ excerpt: "" }), []);
3843

‎app/src/utils/clipboardWrite.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,27 @@ describe("writeClipboardText", () => {
5555
});
5656
await expect(writeClipboardText("x")).resolves.toBe(false);
5757
});
58+
59+
it("兜底路径不毁宿主选区:removeChild 后原 Range 恢复(审查 P3-1)", async () => {
60+
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
61+
stubExecCommand(() => true);
62+
// 宿主预置文档选区(阅读态正文高亮 / 编辑态光标锚定同经文档 Selection)
63+
const host = document.createElement("div");
64+
host.textContent = "宿主选中文字";
65+
document.body.appendChild(host);
66+
const sel = window.getSelection();
67+
expect(sel).toBeTruthy();
68+
const range = document.createRange();
69+
range.selectNodeContents(host);
70+
sel!.removeAllRanges();
71+
sel!.addRange(range);
72+
expect(sel!.toString()).toBe("宿主选中文字");
73+
await expect(writeClipboardText("片段")).resolves.toBe(true);
74+
// ta.select() 覆盖 + 兜底 textarea 移除后——原选区按快照恢复,无残留
75+
expect(sel!.rangeCount).toBe(1);
76+
expect(sel!.toString()).toBe("宿主选中文字");
77+
expect(sel!.anchorNode).toBe(host);
78+
expect(document.querySelectorAll("textarea")).toHaveLength(0);
79+
document.body.removeChild(host);
80+
});
5881
});

0 commit comments

Comments
 (0)