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 ? ( ) : (
- {isTreeModeVisible ? ( -
-
- -
-
- ) : null} {shouldMountOriginalPreview ? ( @@ -406,6 +375,20 @@ type ChunkSectionTreeLayout = { readonly yOffset: number; }; +type SectionTreeZoomDirection = "in" | "out"; + +type SectionTreePan = { + readonly x: number; + readonly y: number; +}; + +type SectionTreeDragState = { + readonly panStartX: number; + readonly panStartY: number; + readonly pointerStartX: number; + readonly pointerStartY: number; +}; + const sectionTreeNodeWidth = 208; const sectionTreeNodeHeight = 58; const sectionTreeColumnGap = 254; @@ -417,6 +400,10 @@ const sectionTreeDefaultZoomPercent = 100; const sectionTreeMinimumZoomPercent = 30; const sectionTreeMaximumZoomPercent = 140; const sectionTreeZoomStepPercent = 10; +const initialSectionTreePan: SectionTreePan = { + x: 0, + y: 0, +}; const sectionTreePadding = { top: 38, right: 42, @@ -431,6 +418,7 @@ function ChunkSectionTree({ sourceTitle, zoomPercent, onChunkFocus, + onWheelZoom, }: { readonly chunks: readonly ParsedChunkView[]; readonly focusedChunkId: string | null; @@ -438,7 +426,13 @@ function ChunkSectionTree({ readonly sourceTitle: string; readonly zoomPercent: number; readonly onChunkFocus: (chunkId: string | null) => void; + readonly onWheelZoom: (direction: SectionTreeZoomDirection) => void; }): ReactNode { + const [sectionTreePan, setSectionTreePan] = + useState(initialSectionTreePan); + const [sectionTreeDragState, setSectionTreeDragState] = + useState(null); + const sectionTreeZoomSurfaceRef = useRef(null); const sectionTree = useMemo( () => chunksPanelState.buildSectionTree(chunks, sourceTitle), [chunks, sourceTitle], @@ -454,6 +448,70 @@ function ChunkSectionTree({ (layout.height * zoomPercent) / 100, ); const zoomScale: string = formatSectionTreeZoomScale(zoomPercent); + const handlePanStart = useCallback( + (event: ReactMouseEvent): void => { + if (event.button !== 0 || isInteractiveSectionTreeTarget(event.target)) { + return; + } + + event.preventDefault(); + setSectionTreeDragState({ + pointerStartX: event.clientX, + pointerStartY: event.clientY, + panStartX: sectionTreePan.x, + panStartY: sectionTreePan.y, + }); + }, + [sectionTreePan.x, sectionTreePan.y], + ); + + useEffect(() => { + const zoomSurface = sectionTreeZoomSurfaceRef.current; + if (!zoomSurface) return; + + const handleWheelZoom = (event: WheelEvent): void => { + if (event.deltaY === 0) return; + + event.preventDefault(); + onWheelZoom(event.deltaY < 0 ? "in" : "out"); + }; + + zoomSurface.addEventListener("wheel", handleWheelZoom, { + passive: false, + }); + + return () => { + zoomSurface.removeEventListener("wheel", handleWheelZoom); + }; + }, [onWheelZoom]); + + useEffect(() => { + if (!sectionTreeDragState) return; + + const handlePanMove = (event: MouseEvent): void => { + setSectionTreePan({ + x: + sectionTreeDragState.panStartX + + event.clientX - + sectionTreeDragState.pointerStartX, + y: + sectionTreeDragState.panStartY + + event.clientY - + sectionTreeDragState.pointerStartY, + }); + }; + const handlePanEnd = (): void => { + setSectionTreeDragState(null); + }; + + window.addEventListener("mousemove", handlePanMove); + window.addEventListener("mouseup", handlePanEnd); + + return () => { + window.removeEventListener("mousemove", handlePanMove); + window.removeEventListener("mouseup", handlePanEnd); + }; + }, [sectionTreeDragState]); return (
) : null}
@@ -519,86 +584,6 @@ function ChunkSectionTree({ ); } -function SectionTreeZoomControls({ - canResetZoom, - canZoomIn, - canZoomOut, - zoomPercent, - onZoomIn, - onZoomOut, - onZoomReset, -}: { - readonly canResetZoom: boolean; - readonly canZoomIn: boolean; - readonly canZoomOut: boolean; - readonly zoomPercent: number; - readonly onZoomIn: () => void; - readonly onZoomOut: () => void; - readonly onZoomReset: () => void; -}): ReactNode { - return ( - -
- - - - - Zoom out - - - {zoomPercent}% - - - - - - Zoom in - - - - - - Reset zoom - -
-
- ); -} - function SectionTreeItem({ focusedChunkId, node, @@ -634,17 +619,17 @@ function SectionTreeItem({ @@ -669,6 +654,13 @@ function SectionTreeItem({ ); } +function isInteractiveSectionTreeTarget(target: EventTarget): boolean { + return ( + target instanceof Element && + target.closest("a,button,input,select,textarea,[role='button']") !== null + ); +} + function getChunkSectionTreeLayout( sectionTree: ChunkSectionTreeNode, ): ChunkSectionTreeLayout {