Skip to content

Commit 5493835

Browse files
committed
feat(notes): REQ-317 阅读/编辑两态选中右键接线与行动联动
1 parent 629b1a0 commit 5493835

9 files changed

Lines changed: 429 additions & 26 deletions

‎app/src/components/ModelCardFromNoteDialog.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ interface Props {
1414
noteTitle: string;
1515
onClose: () => void;
1616
onCreated?: () => void;
17+
/** 批 8(REQ-317):初始定义草稿预填(选区右键「模型卡预填」通道——
18+
* 挂载时一次性读入;对话框每次由父层条件挂载,无需 key 重触发) */
19+
initialExcerpt?: string;
1720
}
1821

1922
const overlayStyle: React.CSSProperties = {
@@ -34,9 +37,9 @@ const cardStyle: React.CSSProperties = {
3437
};
3538
const btn: React.CSSProperties = { padding: "5px 12px", cursor: "pointer", fontSize: 12, borderRadius: 6, border: "1px solid #e5e7eb", background: "#fff", color: "#374151" };
3639

37-
export default function ModelCardFromNoteDialog({ noteId, noteTitle, onClose, onCreated }: Props) {
40+
export default function ModelCardFromNoteDialog({ noteId, noteTitle, onClose, onCreated, initialExcerpt = "" }: Props) {
3841
const [name, setName] = useState("");
39-
const [excerpt, setExcerpt] = useState("");
42+
const [excerpt, setExcerpt] = useState(initialExcerpt);
4043
const [err, setErr] = useState("");
4144
const [msg, setMsg] = useState("");
4245
const [busy, setBusy] = useState(false);

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* 且未传时不渲染(回归:既有标题栏按钮布局不受影响)。
77
*/
88
import { afterEach, describe, expect, it, vi } from "vitest";
9-
import { cleanup, render, screen } from "@testing-library/react";
9+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
1010
import type { Note } from "../types";
1111
import NoteReadingView from "./NoteReadingView";
1212

@@ -67,3 +67,53 @@ describe("NoteReadingView 标题栏扩展插槽", () => {
6767
expect(screen.getByRole("button", { name: "📌" })).toBeTruthy();
6868
});
6969
});
70+
71+
describe("阅读态正文选区右键菜单(批 8 REQ-317)", () => {
72+
const renderSel = (extraProps: Partial<React.ComponentProps<typeof NoteReadingView>>) =>
73+
render(
74+
<NoteReadingView
75+
note={baseNote()}
76+
editing={false}
77+
onEdit={noop}
78+
onPinToggle={noop}
79+
onDelete={noop}
80+
onTagClick={noop}
81+
onOpenSession={noop}
82+
onTaskToggle={noop}
83+
onImageOpen={noop}
84+
{...extraProps}
85+
/>,
86+
);
87+
88+
it("空选区右键不弹菜单(维持现状静默基线)", () => {
89+
renderSel({});
90+
const body = document.querySelector("[data-note-read-body]");
91+
fireEvent.contextMenu(body!, { clientX: 60, clientY: 60 });
92+
expect(screen.queryByTestId("note-sel-menu")).toBeNull();
93+
});
94+
95+
it("正文内选区右键 → 阅读态菜单(无加入行动项),转问题上抛快照文本", () => {
96+
const onSelectionAction = vi.fn();
97+
renderSel({ onSelectionAction });
98+
const body = document.querySelector("[data-note-read-body]");
99+
expect(body).toBeTruthy();
100+
const para = body!.querySelector("p");
101+
expect(para).toBeTruthy();
102+
// jsdom 无真实 Selection——用形状桩替 window.getSelection(handler 只读
103+
// isCollapsed/rangeCount/锚焦/toString,锚=正文容器内段落文本节点)
104+
const selStub = {
105+
isCollapsed: false,
106+
rangeCount: 1,
107+
anchorNode: para!.firstChild,
108+
focusNode: para!.firstChild,
109+
toString: () => "选中段落文本",
110+
};
111+
vi.spyOn(window, "getSelection").mockReturnValue(selStub as unknown as Selection);
112+
fireEvent.contextMenu(para!, { clientX: 80, clientY: 80 });
113+
expect(screen.getByTestId("note-sel-menu")).toBeTruthy();
114+
expect(screen.getByTestId("note-sel-copy")).toBeTruthy();
115+
expect(screen.queryByTestId("note-sel-addTask")).toBeNull(); // 阅读态 V1 隐藏
116+
fireEvent.click(screen.getByTestId("note-sel-toQuestion"));
117+
expect(onSelectionAction).toHaveBeenCalledWith("toQuestion", "选中段落文本");
118+
});
119+
});

‎app/src/components/NoteReadingView.tsx‎

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ import ColumnBar from "./ColumnBar";
1717
import { fmtDate, parseTags } from "./NoteListView";
1818
// 批 3(用户问题9):阅读容器底部留白与编辑双面同源(末段不再贴底无法上移)
1919
import { BOTTOM_BREATHER_CSS } from "../utils/contentBreather";
20+
// 批 8(REQ-317):正文选区右键菜单——纯判定/快照 util + 共享菜单组件
21+
import {
22+
meaningfulSelection,
23+
selectNodeContents,
24+
type SelectionActionId,
25+
type SelectionNoteAction,
26+
} from "../utils/noteSelectionMenu";
27+
import SelectionActionMenu from "./note-selection/SelectionActionMenu";
2028

2129
interface Props {
2230
note: Note;
@@ -40,19 +48,28 @@ interface Props {
4048
onOpenSession: (sessionId: number) => void;
4149
onTaskToggle: (newContent: string) => void;
4250
onImageOpen: (src: string, title?: string) => void;
51+
/** 批 8(REQ-317):正文选区菜单行动类动作上抛(转问题/模型卡预填——
52+
* 复制/全选就地执行,行动类由 NotesPage 层对话框/命令处理) */
53+
onSelectionAction?: (action: SelectionNoteAction, text: string) => void;
4354
}
4455

4556
export default function NoteReadingView({
4657
note, editing, editor, auxPanels, headerExtra,
4758
outlineFolded = false, externalSearch = null, onToggleOutline,
4859
onEdit, onPinToggle, onDelete, onTagClick, onOpenSession, onTaskToggle, onImageOpen,
60+
onSelectionAction,
4961
}: Props) {
5062
// A2:搜索高亮(M6:匹配集合=渲染产物只读查询,计数经此状态驱动)
5163
const [searchQuery, setSearchQuery] = useState("");
5264
const [searchActive, setSearchActive] = useState(false);
5365
const [searchIndex, setSearchIndex] = useState(0);
5466
const [searchMatches, setSearchMatches] = useState<HTMLElement[]>([]);
5567
const contentRef = useRef<HTMLDivElement>(null);
68+
// 批 8(REQ-317):正文选区右键菜单态(x/y/文本快照——菜单打开瞬间捕获,
69+
// 点击动作不再依赖 DOM 选区存活)与正文容器 ref(选区包含判定/全选目标;
70+
// 容器=纯 Markdown 包装层,auxPanels 在容器外——跨容器选区不弹菜单)
71+
const [selMenu, setSelMenu] = useState<{ x: number; y: number; text: string } | null>(null);
72+
const bodyRef = useRef<HTMLDivElement | null>(null);
5673
// v0.19.1 审查 L1:已注入请求登记({noteId,key})——消费后不复活:
5774
// 用户关闭搜索/离开笔记再普通打开同一笔记时,旧命中词不得自动重现
5875
const injectedRef = useRef<{ noteId: number; key: number } | null>(null);
@@ -119,6 +136,29 @@ export default function NoteReadingView({
119136

120137
const selectedTags = parseTags(note);
121138

139+
// 批 8(REQ-317):正文区右键——仅在正文容器内存在非空选区时弹菜单;
140+
// 空选区/跨容器选区(auxPanels/链接等既有交互)维持现状(原生菜单已被
141+
// BrowserChrome 抑制 → 静默,不新增空选区菜单)
142+
const handleBodyContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
143+
const text = meaningfulSelection(window.getSelection(), bodyRef.current);
144+
if (!text) return;
145+
e.preventDefault();
146+
e.stopPropagation(); // 自绘菜单范式:不到 window(BrowserChrome 兜底仍在)
147+
setSelMenu({ x: e.clientX, y: e.clientY, text });
148+
};
149+
// 菜单动作分发:全选=阅读容器全文(最小可行方案,纯 DOM Range);行动类上抛
150+
const handleSelAction = (action: Exclude<SelectionActionId, "copy">) => {
151+
const text = selMenu?.text ?? "";
152+
if (action === "selectAll") {
153+
selectNodeContents(bodyRef.current);
154+
return;
155+
}
156+
// 阅读态菜单 V1 不含 addTask(DOM↔源码行映射留后续 TD-2026-09-09-C)——
157+
// 防御性忽略其余动作(类型上 action 仍是全集,注释说明 Why)
158+
if (action !== "toQuestion" && action !== "toModelCard") return;
159+
if (text && onSelectionAction) onSelectionAction(action, text);
160+
};
161+
122162
return (
123163
<>
124164
{/* v0.11.5/v0.15 大纲面板(统一列折叠体系:折叠 → 26px 窄条) */}
@@ -244,16 +284,33 @@ export default function NoteReadingView({
244284
) : (
245285
<div ref={contentRef} style={{ flex: 1, overflowY: "auto", padding: `16px 16px ${BOTTOM_BREATHER_CSS}`, fontSize: 14, lineHeight: 1.8 }}>
246286
{auxPanels}
247-
<NoteMarkdown
248-
note={note}
249-
searchQuery={searchActive ? searchQuery : ""}
250-
onTaskToggle={onTaskToggle}
251-
onOpenSession={onOpenSession}
252-
onImageOpen={onImageOpen}
253-
/>
287+
{/* 批 8(REQ-317):正文容器=纯 Markdown 包装层(选区判定/全选
288+
scope;auxPanels 在容器外保持既有交互不被右键菜单截获) */}
289+
<div ref={bodyRef} data-note-read-body="" onContextMenu={handleBodyContextMenu}>
290+
<NoteMarkdown
291+
note={note}
292+
searchQuery={searchActive ? searchQuery : ""}
293+
onTaskToggle={onTaskToggle}
294+
onOpenSession={onOpenSession}
295+
onImageOpen={onImageOpen}
296+
/>
297+
</div>
254298
</div>
255299
)}
256300
</div>
301+
302+
{/* 批 8(REQ-317):正文选区右键菜单(阅读态——复制就地、全选=容器全文、
303+
加入行动 V1 隐藏[DOM↔源码行映射留后续 TD-2026-09-09-C]、行动类上抛) */}
304+
{!editing && selMenu && (
305+
<SelectionActionMenu
306+
mode="reading"
307+
x={selMenu.x}
308+
y={selMenu.y}
309+
text={selMenu.text}
310+
onClose={() => setSelMenu(null)}
311+
onAction={handleSelAction}
312+
/>
313+
)}
257314
</>
258315
);
259316
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,44 @@ describe("RichEditorView 核心编辑", () => {
8888
});
8989
});
9090

91+
describe("RichEditorView 选区右键菜单(批 8 REQ-317)", () => {
92+
it("空选区右键不弹菜单(维持现状静默基线)", async () => {
93+
render(<RichEditorView note={baseNote} onCancel={vi.fn()} />);
94+
await waitFor(() => expect(cmContent().textContent).toContain("第一行"));
95+
fireEvent.contextMenu(cmContent(), { clientX: 120, clientY: 120 });
96+
expect(screen.queryByTestId("note-sel-menu")).toBeNull();
97+
});
98+
99+
it("Ctrl+A 全选后右键 → 菜单含加入行动;点击后选区末行后插任务行", async () => {
100+
render(<RichEditorView note={baseNote} onCancel={vi.fn()} />);
101+
await waitFor(() => expect(cmContent().textContent).toContain("第一行"));
102+
fireEvent.keyDown(cmContent(), { key: "a", ctrlKey: true });
103+
fireEvent.contextMenu(cmContent(), { clientX: 120, clientY: 120 });
104+
expect(await screen.findByTestId("note-sel-menu")).toBeTruthy();
105+
expect(screen.getByTestId("note-sel-copy")).toBeTruthy();
106+
expect(screen.getByTestId("note-sel-selectAll")).toBeTruthy();
107+
expect(screen.getByTestId("note-sel-addTask")).toBeTruthy();
108+
expect(screen.getByTestId("note-sel-toQuestion")).toBeTruthy();
109+
expect(screen.getByTestId("note-sel-toModelCard")).toBeTruthy();
110+
fireEvent.click(screen.getByTestId("note-sel-addTask"));
111+
// 全选快照单行化(含换行折叠)成任务文本,插在选区结束行(末行)之后;
112+
// CM .cm-content 的 textContent 按行 div 拼接无换行符(行内断言见纯函数域)
113+
await waitFor(() => {
114+
expect(cmContent().textContent).toBe("第一行第二行- [ ] 第一行 第二行");
115+
});
116+
});
117+
118+
it("行动类(转为问题)经 onSelectionAction 上抛(含快照文本)", async () => {
119+
const onSelectionAction = vi.fn();
120+
render(<RichEditorView note={baseNote} onCancel={vi.fn()} onSelectionAction={onSelectionAction} />);
121+
await waitFor(() => expect(cmContent().textContent).toContain("第一行"));
122+
fireEvent.keyDown(cmContent(), { key: "a", ctrlKey: true });
123+
fireEvent.contextMenu(cmContent(), { clientX: 120, clientY: 120 });
124+
fireEvent.click(await screen.findByTestId("note-sel-toQuestion"));
125+
expect(onSelectionAction).toHaveBeenCalledWith("toQuestion", "第一行\n第二行");
126+
});
127+
});
128+
91129
describe("RichEditorView 草稿恢复层", () => {
92130
it("存在更新草稿 → 提示恢复;点恢复 → title/content 生效", async () => {
93131
localStorage.setItem(

‎app/src/components/RichEditorView.tsx‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,18 @@ import NoteEditView, { type NoteEditHandle } from "./NoteEditView";
2828
import NoteColorPicker from "./NoteColorPicker";
2929
// 批 3(用户问题9):CM 内容底部留白与阅读/textarea 同源(末行可滚离底边)
3030
import { BOTTOM_BREATHER_CSS } from "../utils/contentBreather";
31+
// 批 8(REQ-317):编辑态选区右键菜单——插入计划纯函数 + 共享菜单组件
32+
import { planTaskLineInsert, type SelectionActionId, type SelectionNoteAction } from "../utils/noteSelectionMenu";
33+
import SelectionActionMenu from "./note-selection/SelectionActionMenu";
3134

3235
interface Props {
3336
note: Note;
3437
onCancel: () => void;
3538
/** 图片点击放大回调(透传 NotesPage 的 ImagePreviewOverlay) */
3639
onImageOpen?: (url: string, title?: string) => void;
40+
/** 批 8(REQ-317):选区菜单行动类动作上抛(转问题/模型卡预填——复制/
41+
* 全选/加入行动就地执行,行动类由 NotesPage 层对话框/命令处理) */
42+
onSelectionAction?: (action: SelectionNoteAction, text: string) => void;
3743
}
3844

3945
const TOOLBAR_BTN: React.CSSProperties = {
@@ -51,7 +57,7 @@ const editorTheme = EditorView.theme({
5157
});
5258

5359
const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView(
54-
{ note, onCancel, onImageOpen }, ref,
60+
{ note, onCancel, onImageOpen, onSelectionAction }, ref,
5561
) {
5662
const [title, setTitle] = useState(note.title);
5763
// CM 非受控持有正文;content state 仅作草稿恢复时的外部 doc 同步源
@@ -62,17 +68,23 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
6268
const [draftPrompt, setDraftPrompt] = useState<{ title: string; content: string } | null>(null);
6369
// v0.16.1:荧光笔色板弹层开合(受控——选色/默认黄/点击外部关闭)
6470
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);
6574

6675
// refs 快照(卸载/定时器闭包取最新值,防 state 闭包过期——同 NoteEditView)
6776
const titleRef = useRef(title);
6877
const contentRef = useRef(content);
6978
// 内联回调 ref:CM extensions 构造后不可更新,keymap/plugin 闭包必须经 ref 取最新
7079
const onCancelRef = useRef(onCancel);
7180
const onImageOpenRef = useRef(onImageOpen);
81+
// 批 8:onSelectionAction 同 ref 模式(CM extension 与菜单动作闭包取最新)
82+
const onSelectionActionRef = useRef(onSelectionAction);
7283
useEffect(() => { titleRef.current = title; }, [title]);
7384
useEffect(() => { contentRef.current = content; }, [content]);
7485
useEffect(() => { onCancelRef.current = onCancel; }, [onCancel]);
7586
useEffect(() => { onImageOpenRef.current = onImageOpen; }, [onImageOpen]);
87+
useEffect(() => { onSelectionActionRef.current = onSelectionAction; }, [onSelectionAction]);
7688

7789
// ── v0.15 剪贴板图片:粘贴即落盘(import_note_image_b64)+ 插入相对引用 ──
7890
const onInsertImage = useCallback((rel: string) => {
@@ -125,6 +137,40 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
125137
autosave.scheduleDraftWrite();
126138
}, [autosave.markDirty, autosave.scheduleDraftWrite]);
127139

140+
// 批 8(REQ-317):编辑态选区动作就地执行(复制已在菜单内用快照文本完成)
141+
const runSelAction = (action: Exclude<SelectionActionId, "copy">) => {
142+
const view = viewRef.current;
143+
const snapshotText = selMenu?.text ?? "";
144+
if (action === "selectAll") {
145+
// 全选=CM 既有 select-all 语义(basicSetup Mod-a 同命令路径:主选区覆盖
146+
// 全 doc)——不引 @codemirror/commands(无直接依赖),dispatch 等价
147+
if (!view) return;
148+
view.dispatch({
149+
selection: { anchor: 0, head: view.state.doc.length },
150+
scrollIntoView: true,
151+
});
152+
view.focus();
153+
return;
154+
}
155+
if (action === "addTask") {
156+
// 加入行动:以「选区结束处所在行」为锚插入 `- [ ] <快照文本>` 独立任务行;
157+
// dispatch → 既有 onChange → 自动保存/草稿/任务索引重扫通道(不 bypass)
158+
if (!view || !snapshotText) return;
159+
const plan = planTaskLineInsert(view.state.doc.toString(), view.state.selection.main.to, snapshotText);
160+
if (!plan) return;
161+
view.dispatch({
162+
changes: { from: plan.from, insert: plan.insert },
163+
selection: { anchor: plan.from + plan.insert.length },
164+
userEvent: "input.addtask",
165+
});
166+
view.focus();
167+
return;
168+
}
169+
if (snapshotText && onSelectionActionRef.current) {
170+
onSelectionActionRef.current(action, snapshotText);
171+
}
172+
};
173+
128174
const extensions = useMemo(() => [
129175
basicSetup,
130176
markdown(),
@@ -136,6 +182,23 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
136182
]),
137183
// v0.15:粘贴图片 → 拦截 + 落盘 + 插入引用(文字粘贴走默认行为)
138184
EditorView.domEventHandlers({ paste: (e) => handleImagePaste(e) }),
185+
// 批 8(REQ-317):编辑态选区右键菜单——CM 主选区非空(sliceDoc 非空串)
186+
// 才弹菜单(右键落点通常已由 CM mousedown 语义定选区);空选区维持现状
187+
// (原生菜单已被全局抑制→静默,与阅读态“空选区不弹”取最小一致)
188+
EditorView.domEventHandlers({
189+
// domEventHandlers 回调签名为 (event, view)——事件在前、视图在后(与
190+
// keymap 的 (view, event) 相反),paste 处理同型单参可用
191+
contextmenu: (e, view) => {
192+
const main = view.state.selection.main;
193+
if (main.empty) return false;
194+
const t = view.state.sliceDoc(main.from, main.to);
195+
if (!t.trim()) return false;
196+
e.preventDefault();
197+
e.stopPropagation(); // 自绘菜单范式:到 window 前截停(原生兜底同在)
198+
setSelMenu({ x: e.clientX, y: e.clientY, text: t });
199+
return true;
200+
},
201+
}),
139202
imageDecorationPlugin({ noteId: note.id, onOpen: (url, t) => onImageOpenRef.current?.(url, t) }),
140203
editorTheme,
141204
], [note.id, autosave.saveVersioned, autosave.flushLatestRef, handleImagePaste]);
@@ -316,6 +379,19 @@ const RichEditorView = forwardRef<NoteEditHandle, Props>(function RichEditorView
316379
<div ref={containerRef} style={{ flex: 1, overflow: "hidden" }} />
317380

318381
{status && <p style={{ padding: "4px 16px", fontSize: 12, color: "#047857" }}>{status}</p>}
382+
383+
{/* 批 8(REQ-317):编辑态选区右键菜单(复制就地、全选=CM select-all、
384+
加入行动=选区内容插任务行走既有保存通道、行动类上抛 NotesPage) */}
385+
{selMenu && (
386+
<SelectionActionMenu
387+
mode="editing"
388+
x={selMenu.x}
389+
y={selMenu.y}
390+
text={selMenu.text}
391+
onClose={() => setSelMenu(null)}
392+
onAction={runSelAction}
393+
/>
394+
)}
319395
</div>
320396
);
321397
});

0 commit comments

Comments
 (0)