diff --git a/src/components/chunks-panel.test.ts b/src/components/chunks-panel.test.ts
index 051aa6d..363d008 100644
--- a/src/components/chunks-panel.test.ts
+++ b/src/components/chunks-panel.test.ts
@@ -176,7 +176,7 @@ describe("ChunksPanel", () => {
expect(cardMinimumWidth).toBeGreaterThanOrEqual(treeWidth);
});
- it("zooms the section tree while keeping the scaled surface measurable", async () => {
+ it("does not render section tree zoom controls over the canvas", async () => {
const user = userEvent.setup();
render(
@@ -196,45 +196,55 @@ describe("ChunksPanel", () => {
await user.click(screen.getByRole("button", { name: "Tree" }));
- const tree = screen.getByRole("tree", { name: "Parsed chunk sections" });
- const surface = screen.getByTestId("chunk-section-tree-zoom-surface");
- const initialSurfaceWidth = Number.parseInt(surface.style.width, 10);
+ expect(
+ screen.queryByTestId("chunk-section-tree-zoom-overlay"),
+ ).toBeNull();
+ expect(
+ screen.queryByRole("group", { name: "Section tree zoom" }),
+ ).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: "Zoom in section tree" }),
+ ).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: "Zoom out section tree" }),
+ ).toBeNull();
+ });
- expect(tree.style.transform).toBe("scale(1)");
- expect(screen.getByText("100%")).toBeTruthy();
+ it("allows the section tree to zoom out to 30 percent with the mouse wheel", async () => {
+ const user = userEvent.setup();
- await user.click(
- screen.getByRole("button", { name: "Zoom in section tree" }),
+ render(
+ React.createElement(C, {
+ chunks: [
+ {
+ chunkId: "chunk_1",
+ type: "text",
+ content: "Overview text",
+ sectionPath: "manual.pdf/Overview/Product/Robotics",
+ sourceTitle: "manual.pdf",
+ },
+ ],
+ selectedSource: "manual.pdf",
+ }),
);
- const zoomedInSurfaceWidth = Number.parseInt(surface.style.width, 10);
+ await user.click(screen.getByRole("button", { name: "Tree" }));
- expect(tree.style.transform).toBe("scale(1.1)");
- expect(screen.getByText("110%")).toBeTruthy();
- expect(zoomedInSurfaceWidth).toBeGreaterThan(initialSurfaceWidth);
+ const tree = screen.getByRole("tree", { name: "Parsed chunk sections" });
+ const surface = screen.getByTestId("chunk-section-tree-zoom-surface");
+ const initialSurfaceWidth = Number.parseInt(surface.style.width, 10);
- await user.click(
- screen.getByRole("button", { name: "Zoom out section tree" }),
- );
- await user.click(
- screen.getByRole("button", { name: "Zoom out section tree" }),
- );
+ for (let i = 0; i < 7; i += 1) {
+ fireEvent.wheel(surface, { deltaY: 120 });
+ }
const zoomedOutSurfaceWidth = Number.parseInt(surface.style.width, 10);
- expect(tree.style.transform).toBe("scale(0.9)");
- expect(screen.getByText("90%")).toBeTruthy();
- expect(zoomedOutSurfaceWidth).toBeLessThan(zoomedInSurfaceWidth);
-
- await user.click(
- screen.getByRole("button", { name: "Reset section tree zoom" }),
- );
-
- expect(tree.style.transform).toBe("scale(1)");
- expect(screen.getByText("100%")).toBeTruthy();
+ expect(tree.style.transform).toBe("scale(0.3)");
+ expect(zoomedOutSurfaceWidth).toBeLessThan(initialSurfaceWidth);
});
- it("allows the section tree to zoom out to 30 percent", async () => {
+ it("zooms the section tree with the mouse wheel", async () => {
const user = userEvent.setup();
render(
@@ -256,24 +266,24 @@ describe("ChunksPanel", () => {
const tree = screen.getByRole("tree", { name: "Parsed chunk sections" });
const surface = screen.getByTestId("chunk-section-tree-zoom-surface");
- const initialSurfaceWidth = Number.parseInt(surface.style.width, 10);
- const zoomOutButton = screen.getByRole("button", {
- name: "Zoom out section tree",
+ const zoomInEvent = new WheelEvent("wheel", {
+ cancelable: true,
+ deltaY: -120,
});
- for (let i = 0; i < 7; i += 1) {
- await user.click(zoomOutButton);
- }
+ act(() => {
+ surface.dispatchEvent(zoomInEvent);
+ });
- const zoomedOutSurfaceWidth = Number.parseInt(surface.style.width, 10);
+ expect(zoomInEvent.defaultPrevented).toBe(true);
+ expect(tree.style.transform).toBe("scale(1.1)");
- expect(tree.style.transform).toBe("scale(0.3)");
- expect(screen.getByText("30%")).toBeTruthy();
- expect(zoomedOutSurfaceWidth).toBeLessThan(initialSurfaceWidth);
- expect(zoomOutButton.hasAttribute("disabled")).toBe(true);
+ fireEvent.wheel(surface, { deltaY: 120 });
+
+ expect(tree.style.transform).toBe("scale(1)");
});
- it("keeps section tree zoom controls fixed at the chunk panel top left", async () => {
+ it("pans the section tree canvas by dragging the background", async () => {
const user = userEvent.setup();
render(
@@ -293,17 +303,46 @@ describe("ChunksPanel", () => {
await user.click(screen.getByRole("button", { name: "Tree" }));
- const overlay = screen.getByTestId("chunk-section-tree-zoom-overlay");
- const scrollContent = screen.getByTestId("chunks-scroll-content");
+ const surface = screen.getByTestId("chunk-section-tree-zoom-surface");
+ const tree = screen.getByRole("tree", { name: "Parsed chunk sections" });
- expect(overlay.className).toContain("absolute");
- expect(overlay.className).toContain("left-3");
- expect(overlay.className).not.toContain("right-3");
- expect(overlay.className).toContain("top-3");
- expect(scrollContent.contains(overlay)).toBe(false);
- expect(
- screen.getByRole("group", { name: "Section tree zoom" }),
- ).toBeTruthy();
+ fireEvent.mouseDown(surface, { button: 0, clientX: 100, clientY: 90 });
+ fireEvent.mouseMove(window, { clientX: 142, clientY: 126 });
+ fireEvent.mouseUp(window);
+
+ expect(tree.style.left).toBe("42px");
+ expect(tree.style.top).toBe("36px");
+ expect(surface.className).toContain("cursor-grab");
+ });
+
+ it("uses pointer cursor and Violet colors for clickable section tree chunk nodes", async () => {
+ const user = userEvent.setup();
+
+ render(
+ React.createElement(C, {
+ chunks: [
+ {
+ chunkId: "robotics_chunk",
+ type: "text",
+ content: "Robotics details",
+ sectionPath: "manual.pdf/Outlook/Robotics",
+ sourceTitle: "manual.pdf",
+ },
+ ],
+ selectedSource: "manual.pdf",
+ }),
+ );
+
+ await user.click(screen.getByRole("button", { name: "Tree" }));
+
+ const chunkNode = screen.getByRole("button", {
+ name: /Robotics details\s*Text/,
+ });
+
+ expect(chunkNode.className).toContain("cursor-pointer");
+ expect(chunkNode.className).toContain("border-violet-200");
+ expect(chunkNode.className).toContain("bg-violet-50");
+ expect(chunkNode.className).toContain("hover:bg-violet-100");
});
it("returns to the list and focuses a chunk when its tree node is clicked", async () => {
diff --git a/src/components/chunks-panel.tsx b/src/components/chunks-panel.tsx
index cc77079..3add4cc 100644
--- a/src/components/chunks-panel.tsx
+++ b/src/components/chunks-panel.tsx
@@ -2,10 +2,12 @@
import {
type CSSProperties,
+ type MouseEvent as ReactMouseEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
+ useRef,
useState,
} from "react";
import { type VirtualItem } from "@tanstack/react-virtual";
@@ -18,21 +20,11 @@ import {
import {
FilePlus2,
Layers,
- RotateCcw,
UploadCloud,
- ZoomIn,
- ZoomOut,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SourceOriginalPreview } from "@/components/source-original-preview";
import { SourceUploadDialog } from "@/components/source-upload-dialog";
-import { Button } from "@/components/ui/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
import { useChunksPanelWorkflow } from "@/components/chunks-panel-workflow";
import { ParsedChunkCard } from "@/components/parsed-chunk-card";
import { chunksPanelState } from "@/components/chunks-panel-state";
@@ -153,31 +145,24 @@ export function ChunksPanel({
},
[requestChunkFocus],
);
- const canZoomSectionTreeOut: boolean =
- sectionTreeZoomPercent > sectionTreeMinimumZoomPercent;
- const canZoomSectionTreeIn: boolean =
- sectionTreeZoomPercent < sectionTreeMaximumZoomPercent;
- const canResetSectionTreeZoom: boolean =
- sectionTreeZoomPercent !== sectionTreeDefaultZoomPercent;
- const handleSectionTreeZoomOut = useCallback((): void => {
- setSectionTreeZoomPercent((currentZoomPercent) =>
- Math.max(
- sectionTreeMinimumZoomPercent,
- currentZoomPercent - sectionTreeZoomStepPercent,
- ),
- );
- }, []);
- const handleSectionTreeZoomIn = useCallback((): void => {
- setSectionTreeZoomPercent((currentZoomPercent) =>
- Math.min(
- sectionTreeMaximumZoomPercent,
- currentZoomPercent + sectionTreeZoomStepPercent,
- ),
- );
- }, []);
- const handleSectionTreeZoomReset = useCallback((): void => {
- setSectionTreeZoomPercent(sectionTreeDefaultZoomPercent);
- }, []);
+ const handleSectionTreeWheelZoom = useCallback(
+ (direction: SectionTreeZoomDirection): void => {
+ setSectionTreeZoomPercent((currentZoomPercent) => {
+ if (direction === "in") {
+ return Math.min(
+ sectionTreeMaximumZoomPercent,
+ currentZoomPercent + sectionTreeZoomStepPercent,
+ );
+ }
+
+ return Math.max(
+ sectionTreeMinimumZoomPercent,
+ currentZoomPercent - sectionTreeZoomStepPercent,
+ );
+ });
+ },
+ [],
+ );
const headerTitle = focusedChunkId ? "Referenced Chunks" : "Parsed Chunks";
const shouldMountOriginalPreview =
visibleView === "original" ||
@@ -312,12 +297,14 @@ export function ChunksPanel({
)
) : isTreeModeVisible ? (