diff --git a/src/components/threeDimensional/ThreeDimensionalTimeline.test.tsx b/src/components/threeDimensional/ThreeDimensionalTimeline.test.tsx index 8b7a4b1..1b1f67b 100644 --- a/src/components/threeDimensional/ThreeDimensionalTimeline.test.tsx +++ b/src/components/threeDimensional/ThreeDimensionalTimeline.test.tsx @@ -1,11 +1,18 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; +import type { + DraggableCoreProps, + DraggableData, + DraggableEvent, +} from "react-draggable"; -const mockDraggable = jest.fn(({ children }: { children: ReactNode }) => children); +const mockDraggableCore = jest.fn( + ({ children }: Partial & { children: ReactNode }) => children, +); jest.mock("react-draggable", () => ({ __esModule: true, - default: (props: { children: ReactNode }) => mockDraggable(props), + DraggableCore: (props: { children: ReactNode }) => mockDraggableCore(props), })); import { ThreeDimensionalTimeline } from "./ThreeDimensionalTimeline"; @@ -31,7 +38,7 @@ describe("ThreeDimensionalTimeline", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it("allows the timeline to be dragged vertically away from the bottom", () => { + it("tracks the absolute pointer displacement when drag events are skipped", () => { render( { />, ); - expect(mockDraggable).toHaveBeenLastCalledWith( - expect.not.objectContaining({ bounds: expect.anything() }), + const timeline = screen.getByRole("region", { name: "三维场景时间轴" }); + const dragShield = timeline.previousElementSibling as HTMLElement; + const dragProps = mockDraggableCore.mock.lastCall?.[0]; + + expect(dragProps).toEqual( + expect.objectContaining({ + handle: ".timeline-drag-handle", + cancel: "button, input, select, [role='dialog']", + }), ); + if (!dragProps?.onStart || !dragProps.onDrag || !dragProps.onStop) { + throw new Error("DraggableCore handlers were not configured"); + } + const { onStart, onDrag, onStop } = dragProps; + const dragEvent = {} as DraggableEvent; + const dragData = ( + x: number, + y: number, + deltaX: number, + deltaY: number, + ): DraggableData => ({ + node: timeline, + x, + y, + deltaX, + deltaY, + lastX: x - deltaX, + lastY: y - deltaY, + }); + + act(() => { + onStart(dragEvent, dragData(100, 200, 0, 0)); + onDrag(dragEvent, dragData(360, 80, 20, -10)); + }); + + expect(dragShield.style.display).toBe("block"); + expect(timeline).toHaveStyle({ + transform: "translate3d(260px, -120px, 0)", + }); + + act(() => { + onStop(dragEvent, dragData(360, 80, 0, 0)); + }); + expect(dragShield.style.display).toBe(""); }); it("uses the themed calendar and returns the selected day", () => { diff --git a/src/components/threeDimensional/ThreeDimensionalTimeline.tsx b/src/components/threeDimensional/ThreeDimensionalTimeline.tsx index 9a8f15a..aaed8b1 100644 --- a/src/components/threeDimensional/ThreeDimensionalTimeline.tsx +++ b/src/components/threeDimensional/ThreeDimensionalTimeline.tsx @@ -9,7 +9,11 @@ import { useState, type ReactNode, } from "react"; -import Draggable from "react-draggable"; +import { + DraggableCore, + type DraggableData, + type DraggableEvent, +} from "react-draggable"; import { FiCalendar, FiChevronDown, @@ -78,7 +82,15 @@ export function ThreeDimensionalTimeline({ onSelectedDateChange, onCurrentTimeChange, }: ThreeDimensionalTimelineProps) { - const timelineRef = useRef(null); + const timelineRef = useRef(null); + const dragShieldRef = useRef(null); + const dragPositionRef = useRef({ x: 0, y: 0 }); + const dragStartRef = useRef<{ + pointerX: number; + pointerY: number; + originX: number; + originY: number; + } | null>(null); const [playing, setPlaying] = useState(false); const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS); const [previewTime, setPreviewTime] = useState(null); @@ -133,6 +145,58 @@ export function ThreeDimensionalTimeline({ ? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100)) : 0; + const applyDragPosition = useCallback((x: number, y: number) => { + dragPositionRef.current = { x, y }; + if (timelineRef.current) { + timelineRef.current.style.transform = `translate3d(${x}px, ${y}px, 0)`; + } + }, []); + + const handleDragStart = useCallback( + (_event: DraggableEvent, data: DraggableData) => { + const { x: originX, y: originY } = dragPositionRef.current; + dragStartRef.current = { + pointerX: data.x, + pointerY: data.y, + originX, + originY, + }; + if (dragShieldRef.current) { + dragShieldRef.current.style.display = "block"; + } + if (timelineRef.current) { + timelineRef.current.style.willChange = "transform"; + } + }, + [], + ); + + const handleDrag = useCallback( + (_event: DraggableEvent, data: DraggableData) => { + const start = dragStartRef.current; + if (!start) return; + applyDragPosition( + start.originX + data.x - start.pointerX, + start.originY + data.y - start.pointerY, + ); + }, + [applyDragPosition], + ); + + const handleDragStop = useCallback( + (event: DraggableEvent, data: DraggableData) => { + handleDrag(event, data); + dragStartRef.current = null; + if (dragShieldRef.current) { + dragShieldRef.current.style.display = ""; + } + if (timelineRef.current) { + timelineRef.current.style.willChange = ""; + } + }, + [handleDrag], + ); + return (
-
- + ); }