diff --git a/docs/selection-copy-context-menu/change.md b/docs/selection-copy-context-menu/change.md new file mode 100644 index 0000000..e384fad --- /dev/null +++ b/docs/selection-copy-context-menu/change.md @@ -0,0 +1,22 @@ +# Intent + +Allow users to copy selected text from Chat conversation content and Task detail content through a localized right-click menu, without changing context menus elsewhere or weakening the Electron renderer boundary. + +Risk: standard change because it adds user-visible behavior across shared UI, Chat, Task, localization, and tests, but it does not add IPC, preload capabilities, persistence, or privileged Main-process access. + +# Spec + +- A non-whitespace DOM selection fully contained in the Chat transcript or Task detail opens a one-item context menu at the right-click location. +- The item is labeled `Copy` in English and `复制` in Simplified Chinese. +- Activating the item writes the exact captured selection, including meaningful surrounding whitespace, through the renderer's existing scoped Clipboard API usage. +- Empty, whitespace-only, out-of-region, or partially out-of-region selections do not open the added menu and do not have their context-menu event consumed. +- Chat history and other existing custom context menus remain outside the new copy regions. +- Existing selection and keyboard-copy behavior remains unchanged; no new Electron IPC or preload capability is introduced. + +# Plan + +1. Add a reusable selection-copy region that validates selection containment, captures exact text, positions and dismisses a localized menu, and writes the captured value to the clipboard. +2. Apply it only to the Chat transcript and Task detail article while preserving their existing DOM element types and classes. +3. Add English and Chinese localization plus themed menu styling. +4. Add focused tests for selection validation, copy behavior, localization, region integration, and Chat history menu isolation. +5. Run focused tests, `pnpm typecheck`, `pnpm lint`, `pnpm i18n:check`, and `pnpm test`; record manual Electron verification status separately. diff --git a/src/components/SelectionCopyRegion.test.tsx b/src/components/SelectionCopyRegion.test.tsx new file mode 100644 index 0000000..1c6af25 --- /dev/null +++ b/src/components/SelectionCopyRegion.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setLanguage } from "../i18n"; +import { + SelectionCopyRegion, + selectionTextWithin, +} from "./SelectionCopyRegion"; + +const writeText = vi.fn().mockResolvedValue(undefined); + +function selectContents(node: Node): Selection { + const selection = window.getSelection()!; + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(node); + selection.addRange(range); + return selection; +} + +beforeEach(() => { + setLanguage("en"); + writeText.mockClear(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + window.getSelection()?.removeAllRanges(); +}); + +describe("SelectionCopyRegion", () => { + it("returns the exact non-empty selection only when both ends are in the region", () => { + const { container } = render( + <> + + copied text + + outside + , + ); + const region = screen.getByTestId("region"); + const inside = screen.getByTestId("inside").firstChild!; + const outside = screen.getByTestId("outside").firstChild!; + + expect(selectionTextWithin(region, selectContents(inside))).toBe( + " copied text ", + ); + expect(selectionTextWithin(region, selectContents(outside))).toBeNull(); + + const whitespace = document.createTextNode(" \n"); + container.append(whitespace); + region.append(whitespace); + expect(selectionTextWithin(region, selectContents(whitespace))).toBeNull(); + + const selection = window.getSelection()!; + selection.removeAllRanges(); + const crossingRange = document.createRange(); + crossingRange.setStart(inside, 1); + crossingRange.setEnd(outside, outside.textContent!.length); + selection.addRange(crossingRange); + expect(selectionTextWithin(region, selection)).toBeNull(); + }); + + it("opens only for a valid in-region selection and copies the captured full text", async () => { + render( + + complete selection + , + ); + const region = screen.getByTestId("region"); + selectContents(screen.getByTestId("text").firstChild!); + + fireEvent.contextMenu(region, { clientX: 24, clientY: 30 }); + const copy = screen.getByRole("menuitem", { name: "Copy" }); + expect(copy).toHaveFocus(); + fireEvent.click(copy); + + expect(writeText).toHaveBeenCalledWith(" complete selection "); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("does not consume a context menu without a valid selection", () => { + render( + + No selection + , + ); + const event = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }); + + screen.getByTestId("region").dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("uses the current Chinese copy label", () => { + setLanguage("zh-CN"); + render( + + 可复制文字 + , + ); + selectContents(screen.getByTestId("text").firstChild!); + + fireEvent.contextMenu(screen.getByTestId("region")); + + expect(screen.getByRole("menuitem", { name: "复制" })).toBeInTheDocument(); + }); +}); diff --git a/src/components/SelectionCopyRegion.tsx b/src/components/SelectionCopyRegion.tsx new file mode 100644 index 0000000..c2b1d5e --- /dev/null +++ b/src/components/SelectionCopyRegion.tsx @@ -0,0 +1,137 @@ +import { + useEffect, + useRef, + useState, + type HTMLAttributes, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "../i18n"; + +const MENU_WIDTH = 142; +const MENU_HEIGHT = 35; +const VIEWPORT_MARGIN = 8; + +interface CopyMenuState { + text: string; + left: number; + top: number; +} + +interface SelectionCopyRegionProps extends HTMLAttributes { + as?: "article" | "div"; + elementRef?: (node: HTMLElement | null) => void; +} + +export function selectionTextWithin( + region: HTMLElement, + selection: Selection | null, +): string | null { + if ( + !selection || + selection.rangeCount === 0 || + !selection.anchorNode || + !selection.focusNode || + !region.contains(selection.anchorNode) || + !region.contains(selection.focusNode) + ) + return null; + const text = selection.toString(); + return text.trim() ? text : null; +} + +export function SelectionCopyRegion({ + as: Tag = "div", + children, + elementRef, + onContextMenu, + ...props +}: SelectionCopyRegionProps) { + const { t } = useTranslation(); + const regionRef = useRef(null); + const menuRef = useRef(null); + const [menu, setMenu] = useState(null); + + useEffect(() => { + if (!menu) return; + menuRef.current?.querySelector("button")?.focus(); + const closeOnOutsidePress = (event: MouseEvent) => { + if (!menuRef.current?.contains(event.target as Node)) setMenu(null); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + setMenu(null); + }; + const close = () => setMenu(null); + document.addEventListener("mousedown", closeOnOutsidePress); + document.addEventListener("keydown", closeOnEscape); + window.addEventListener("blur", close); + window.addEventListener("resize", close); + return () => { + document.removeEventListener("mousedown", closeOnOutsidePress); + document.removeEventListener("keydown", closeOnEscape); + window.removeEventListener("blur", close); + window.removeEventListener("resize", close); + }; + }, [menu]); + + const handleContextMenu = (event: ReactMouseEvent) => { + onContextMenu?.(event); + if (event.defaultPrevented) return; + const region = regionRef.current; + const text = region + ? selectionTextWithin(region, window.getSelection()) + : null; + if (!text) { + setMenu(null); + return; + } + event.preventDefault(); + const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - MENU_WIDTH); + const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - MENU_HEIGHT); + setMenu({ + text, + left: Math.max(VIEWPORT_MARGIN, Math.min(event.clientX, maxLeft)), + top: Math.max(VIEWPORT_MARGIN, Math.min(event.clientY, maxTop)), + }); + }; + + return ( + <> + { + regionRef.current = node; + elementRef?.(node); + }} + onContextMenu={handleContextMenu} + > + {children} + + {menu + ? createPortal( +
+ +
, + document.body, + ) + : null} + + ); +} diff --git a/src/features/chat/ChatHistory.test.tsx b/src/features/chat/ChatHistory.test.tsx index cbdac14..f1679a1 100644 --- a/src/features/chat/ChatHistory.test.tsx +++ b/src/features/chat/ChatHistory.test.tsx @@ -203,6 +203,7 @@ describe("ChatHistory", () => { const renameAction = screen.getByRole("menuitem", { name: "Rename Fix tests", }); + expect(screen.queryByRole("menuitem", { name: "Copy" })).toBeNull(); expect(renameAction).toHaveFocus(); fireEvent.click(renameAction); fireEvent.change(screen.getByRole("textbox", { name: "Session name" }), { diff --git a/src/features/chat/ChatView.test.tsx b/src/features/chat/ChatView.test.tsx index 7b51668..275a86f 100644 --- a/src/features/chat/ChatView.test.tsx +++ b/src/features/chat/ChatView.test.tsx @@ -166,6 +166,35 @@ describe("ChatView", () => { expect(screen.queryByText("Start a conversation with pi")).toBeNull(); }); + it("copies a complete selection from rendered conversation content", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const { container } = render( + , + ); + const heading = await screen.findByRole("heading", { + name: "Markdown result", + }); + const selection = window.getSelection()!; + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(heading); + selection.addRange(range); + + fireEvent.contextMenu(container.querySelector(".chat-transcript")!); + fireEvent.click(screen.getByRole("menuitem", { name: "Copy" })); + + expect(writeText).toHaveBeenCalledWith("Markdown result"); + }); + it("renders only the final answer until the work summary is expanded", async () => { render(
-
+ {intro} {state.status === "loading" && (
{t("chat.loadingConversation")}
@@ -174,7 +179,7 @@ function ChatViewContent({ } })} {afterMessages} -
+ {showScrollToBottom && (