fix(3d): stabilize timeline dragging

This commit is contained in:
2026-09-14 12:57:26 +08:00
parent 08ddb4453d
commit 331ea3f094
2 changed files with 131 additions and 11 deletions
@@ -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 { 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", () => ({ jest.mock("react-draggable", () => ({
__esModule: true, __esModule: true,
default: (props: { children: ReactNode }) => mockDraggable(props), DraggableCore: (props: { children: ReactNode }) => mockDraggableCore(props),
})); }));
import { ThreeDimensionalTimeline } from "./ThreeDimensionalTimeline"; import { ThreeDimensionalTimeline } from "./ThreeDimensionalTimeline";
@@ -31,7 +38,7 @@ describe("ThreeDimensionalTimeline", () => {
expect(onClose).toHaveBeenCalledTimes(1); 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( render(
<ThreeDimensionalTimeline <ThreeDimensionalTimeline
selectedDate={new Date("2026-09-11T12:00:00+08:00")} selectedDate={new Date("2026-09-11T12:00:00+08:00")}
@@ -44,9 +51,50 @@ describe("ThreeDimensionalTimeline", () => {
/>, />,
); );
expect(mockDraggable).toHaveBeenLastCalledWith( const timeline = screen.getByRole("region", { name: "三维场景时间轴" });
expect.not.objectContaining({ bounds: expect.anything() }), 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", () => { it("uses the themed calendar and returns the selected day", () => {
@@ -9,7 +9,11 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import Draggable from "react-draggable"; import {
DraggableCore,
type DraggableData,
type DraggableEvent,
} from "react-draggable";
import { import {
FiCalendar, FiCalendar,
FiChevronDown, FiChevronDown,
@@ -78,7 +82,15 @@ export function ThreeDimensionalTimeline({
onSelectedDateChange, onSelectedDateChange,
onCurrentTimeChange, onCurrentTimeChange,
}: ThreeDimensionalTimelineProps) { }: 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 [playing, setPlaying] = useState(false);
const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS); const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS);
const [previewTime, setPreviewTime] = useState<number | null>(null); const [previewTime, setPreviewTime] = useState<number | null>(null);
@@ -133,6 +145,58 @@ export function ThreeDimensionalTimeline({
? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100)) ? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100))
: 0; : 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 ( return (
<div <div
className={clsx( className={clsx(
@@ -140,17 +204,25 @@ export function ThreeDimensionalTimeline({
sidePanelOpen ? "md:right-[416px]" : "md:right-4", 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} nodeRef={timelineRef}
handle=".timeline-drag-handle" handle=".timeline-drag-handle"
cancel="button, input, select, [role='dialog']" cancel="button, input, select, [role='dialog']"
onStart={handleDragStart}
onDrag={handleDrag}
onStop={handleDragStop}
> >
<section <section
ref={timelineRef} ref={timelineRef}
aria-label="三维场景时间轴" aria-label="三维场景时间轴"
className={clsx( className={clsx(
glassClass, 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"> <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>
</div> </div>
</section> </section>
</Draggable> </DraggableCore>
</div> </div>
); );
} }