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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user