fix(3d): stabilize timeline dragging
This commit is contained in:
@@ -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<DraggableCoreProps> & { 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(
|
||||
<ThreeDimensionalTimeline
|
||||
selectedDate={new Date("2026-09-11T12:00:00+08:00")}
|
||||
@@ -44,9 +51,50 @@ describe("ThreeDimensionalTimeline", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLElement>(null);
|
||||
const dragShieldRef = useRef<HTMLDivElement>(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<number | null>(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 (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -140,17 +204,25 @@ export function ThreeDimensionalTimeline({
|
||||
sidePanelOpen ? "md:right-[416px]" : "md:right-4",
|
||||
)}
|
||||
>
|
||||
<Draggable
|
||||
<div
|
||||
ref={dragShieldRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-auto fixed inset-0 z-0 hidden cursor-move"
|
||||
/>
|
||||
<DraggableCore
|
||||
nodeRef={timelineRef}
|
||||
handle=".timeline-drag-handle"
|
||||
cancel="button, input, select, [role='dialog']"
|
||||
onStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onStop={handleDragStop}
|
||||
>
|
||||
<section
|
||||
ref={timelineRef}
|
||||
aria-label="三维场景时间轴"
|
||||
className={clsx(
|
||||
glassClass,
|
||||
"pointer-events-auto relative w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
|
||||
"pointer-events-auto relative z-10 w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<div className="timeline-drag-handle relative flex h-7 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/40 bg-white/10">
|
||||
@@ -248,7 +320,7 @@ export function ThreeDimensionalTimeline({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Draggable>
|
||||
</DraggableCore>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user