mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(ui): make preview panes selectable and copyable (#1127)
The workbench disables selection globally, so the read-only preview panes beside result trees (TODO, Usages, Problems…) could not be selected or copied. The pane opts back in (user-select: text) and uses useSelectionBoundary so drags stay inside it; the line-number gutter stays unselectable so copied text is code only. Co-authored-by: qtvbwfn <5848026+qtvbwfn@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,10 @@
|
||||
font-size: var(--fs-ui);
|
||||
line-height: 19px;
|
||||
color: var(--fg);
|
||||
/* The workbench disables selection globally; preview text stays selectable/copyable. */
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
.empty {
|
||||
display: flex;
|
||||
@@ -41,6 +45,9 @@
|
||||
text-align: right;
|
||||
color: var(--fg-gutter);
|
||||
background-color: var(--bg-app);
|
||||
/* Copying the code leaves the line numbers out. */
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.currentLine .gutter {
|
||||
color: var(--fg-muted);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SourcePreview } from "./SourcePreview";
|
||||
|
||||
/** A stable loader reference (a new function reloads, like the real module-level ones). */
|
||||
const loadOf = (content: string) => () => Promise.resolve(content);
|
||||
|
||||
describe("SourcePreview", () => {
|
||||
it("renders the loaded lines around the current one", async () => {
|
||||
const { container } = render(
|
||||
<SourcePreview
|
||||
path="/p/a.ts"
|
||||
line={1}
|
||||
load={loadOf("one\ntwo\nthree")}
|
||||
highlights={[{ line: 1, ranges: [[0, 3]] }]}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("two");
|
||||
expect(container.querySelector('[data-line="1"] mark')?.textContent).toBe("two");
|
||||
expect(container.querySelector('[data-line="0"]')?.textContent).toContain("one");
|
||||
});
|
||||
|
||||
it("makes its text selectable as a selection boundary", async () => {
|
||||
const { container } = render(<SourcePreview path="/p/a.ts" line={0} load={loadOf("// TODO")} />);
|
||||
await screen.findByText("// TODO");
|
||||
// The scroll pane is a selection scope: drags stay inside, text stays copyable.
|
||||
expect(container.querySelector("[data-selection-scope]")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "re
|
||||
import clsx from "clsx";
|
||||
import { T } from "./T";
|
||||
import styles from "./SourcePreview.module.css";
|
||||
import { useSelectionBoundary } from "./useSelectionBoundary";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export interface SourceHighlight {
|
||||
@@ -33,7 +34,6 @@ export interface SourcePreviewProps {
|
||||
export function SourcePreview({ path, line, highlights = [], load, version = 0, className }: SourcePreviewProps) {
|
||||
const [text, setText] = useState<{ path: string; lines: string[] } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const current = text && text.path === path ? text : null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,12 +56,6 @@ export function SourcePreview({ path, line, highlights = [], load, version = 0,
|
||||
};
|
||||
}, [path, version, load]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const box = scrollRef.current;
|
||||
const el = box?.querySelector<HTMLElement>(`[data-line="${line}"]`);
|
||||
if (box && el) box.scrollTop = el.offsetTop - box.clientHeight / 2 + el.offsetHeight / 2;
|
||||
}, [current, line]);
|
||||
|
||||
if (!path)
|
||||
return (
|
||||
<div className={clsx(styles.preview, styles.empty, className)}>
|
||||
@@ -71,12 +65,43 @@ export function SourcePreview({ path, line, highlights = [], load, version = 0,
|
||||
if (error && !current) return <div className={clsx(styles.preview, styles.empty, className)}>{error}</div>;
|
||||
if (!current) return <div className={clsx(styles.preview, className)} />;
|
||||
|
||||
return <PreviewBody path={path} line={line} lines={current.lines} highlights={highlights} className={className} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendered lines. A separate component so the selection boundary mounts
|
||||
* with the scroll element: the pane's own `path` may go null → set, after
|
||||
* this component's effects already ran, and the ref would stay empty.
|
||||
*/
|
||||
function PreviewBody({
|
||||
path,
|
||||
line,
|
||||
lines,
|
||||
highlights,
|
||||
className,
|
||||
}: {
|
||||
path: string;
|
||||
line: number;
|
||||
lines: string[];
|
||||
highlights: readonly SourceHighlight[];
|
||||
className?: string;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useSelectionBoundary(scrollRef);
|
||||
// Re-run on the loaded lines (not the parent's `current` object): the body
|
||||
// remounts per text, so recentring must fire when the new lines arrive.
|
||||
useLayoutEffect(() => {
|
||||
const box = scrollRef.current;
|
||||
const el = box?.querySelector<HTMLElement>(`[data-line="${line}"]`);
|
||||
if (box && el) box.scrollTop = el.offsetTop - box.clientHeight / 2 + el.offsetHeight / 2;
|
||||
}, [line, path, lines]);
|
||||
|
||||
const byLine = new Map(highlights.map((h) => [h.line, h.ranges]));
|
||||
const start = Math.max(0, line - WINDOW);
|
||||
const end = Math.min(current.lines.length, line + WINDOW);
|
||||
const end = Math.min(lines.length, line + WINDOW);
|
||||
const rows: ReactNode[] = [];
|
||||
for (let n = start; n < end; n++) {
|
||||
const content = current.lines[n] ?? "";
|
||||
const content = lines[n] ?? "";
|
||||
const ranges = byLine.get(n);
|
||||
rows.push(
|
||||
<div key={n} data-line={n} className={clsx(styles.codeLine, n === line && styles.currentLine)}>
|
||||
|
||||
Reference in New Issue
Block a user