Skip to content
This repository has been archived by the owner on Apr 25, 2023. It is now read-only.

Commit

Permalink
feat: add builtin timeline widget (#285)
Browse files Browse the repository at this point in the history
Co-authored-by: KaWaite <34051327+KaWaite@users.noreply.github.com>
Co-authored-by: rot1024 <aayhrot@gmail.com>
  • Loading branch information
3 people committed Aug 27, 2022
1 parent 4fc2415 commit f774ee5
Show file tree
Hide file tree
Showing 34 changed files with 714 additions and 120 deletions.
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -125,7 +125,7 @@
"mini-svg-data-uri": "1.4.4",
"parse-domain": "7.0.0",
"quickjs-emscripten": "0.21.0",
"quickjs-emscripten-sync": "1.4.0",
"quickjs-emscripten-sync": "1.5.1",
"rc-slider": "9.7.5",
"react": "18.1.0",
"react-accessible-accordion": "5.0.0",
Expand Down
3 changes: 2 additions & 1 deletion src/components/atoms/Plugin/hooks/index.ts
Expand Up @@ -39,7 +39,8 @@ export const defaultIsMarshalable = (obj: any): boolean => {
((typeof obj !== "object" || obj === null) && typeof obj !== "function") ||
Array.isArray(obj) ||
Object.getPrototypeOf(obj) === Function.prototype ||
Object.getPrototypeOf(obj) === Object.prototype
Object.getPrototypeOf(obj) === Object.prototype ||
obj instanceof Date
);
};

Expand Down
74 changes: 33 additions & 41 deletions src/components/atoms/Timeline/hooks.ts
Expand Up @@ -74,7 +74,8 @@ const useTimelineInteraction = ({

const scrollThreshold = 30;
const scrollAmount = 20;
const clientX = e.clientX;
const rect = e.currentTarget.getBoundingClientRect();
const clientX = e.clientX - rect.x;
const curTar = e.currentTarget;
const clientWidth = curTar.clientWidth;

Expand Down Expand Up @@ -124,31 +125,39 @@ const useTimelineInteraction = ({

type TimelinePlayerOptions = {
currentTime: number;
onPlay?: TimeEventHandler;
range: Range;
onPlay?: (isPlaying: boolean) => void;
onPlayReversed?: (isPlaying: boolean) => void;
onSpeedChange?: (speed: number) => void;
};

const useTimelinePlayer = ({ currentTime, onPlay, range }: TimelinePlayerOptions) => {
const [playSpeed, setPlaySpeed] = useState(1.0);
const useTimelinePlayer = ({
currentTime,
onPlay,
onPlayReversed,
onSpeedChange,
}: TimelinePlayerOptions) => {
const [isPlaying, setIsPlaying] = useState(false);
const [isPlayingReversed, setIsPlayingReversed] = useState(false);
const syncCurrentTimeRef = useRef(currentTime);
const playTimerRef = useRef<NodeJS.Timer | null>(null);
const onPlaySpeedChange: ChangeEventHandler<HTMLInputElement> = useCallback(e => {
setPlaySpeed(parseInt(e.currentTarget.value, 10) / 10);
const handleOnSpeedChange: ChangeEventHandler<HTMLInputElement> = useCallback(e => {
onSpeedChange?.(parseInt(e.currentTarget.value, 10));
}, []);
const toggleIsPlaying = useCallback(() => {
if (isPlayingReversed) {
setIsPlayingReversed(false);
onPlayReversed?.(false);
}
setIsPlaying(p => !p);
}, [isPlayingReversed]);
onPlay?.(!isPlaying);
}, [isPlayingReversed, isPlaying]);
const toggleIsPlayingReversed = useCallback(() => {
if (isPlaying) {
setIsPlaying(false);
onPlay?.(false);
}
setIsPlayingReversed(p => !p);
}, [isPlaying]);
onPlayReversed?.(!isPlayingReversed);
}, [isPlaying, isPlayingReversed]);
const formattedCurrentTime = useMemo(() => {
const textDate = formatDateForTimeline(currentTime, { detail: true });
const lastIdx = textDate.lastIndexOf(" ");
Expand All @@ -161,35 +170,8 @@ const useTimelinePlayer = ({ currentTime, onPlay, range }: TimelinePlayerOptions
syncCurrentTimeRef.current = currentTime;
}, [currentTime]);

useEffect(() => {
const clearPlayTimer = () => {
if (playTimerRef.current) {
clearInterval(playTimerRef.current);
}
};

if ((!isPlaying && !isPlayingReversed) || !onPlay) {
return clearPlayTimer;
}

const defaultInterval = 10;

playTimerRef.current = setInterval(() => {
const interval = EPOCH_SEC * playSpeed;
if (isPlaying) {
onPlay(Math.min(syncCurrentTimeRef.current + interval, range.end));
}
if (isPlayingReversed) {
onPlay(Math.max(syncCurrentTimeRef.current - interval, range.start));
}
}, defaultInterval);

return clearPlayTimer;
}, [playSpeed, onPlay, isPlaying, isPlayingReversed, range]);

return {
playSpeed,
onPlaySpeedChange,
onSpeedChange: handleOnSpeedChange,
formattedCurrentTime,
isPlaying,
isPlayingReversed,
Expand Down Expand Up @@ -238,10 +220,20 @@ type Option = {
range?: { [K in keyof Range]?: Range[K] };
onClick?: TimeEventHandler;
onDrag?: TimeEventHandler;
onPlay?: TimeEventHandler;
onPlay?: (isPlaying: boolean) => void;
onPlayReversed?: (isPlaying: boolean) => void;
onSpeedChange?: (speed: number) => void;
};

export const useTimeline = ({ currentTime, range: _range, onClick, onDrag, onPlay }: Option) => {
export const useTimeline = ({
currentTime,
range: _range,
onClick,
onDrag,
onPlay,
onPlayReversed,
onSpeedChange,
}: Option) => {
const [zoom, setZoom] = useState(1);
const range = useMemo(() => {
const range = getRange(_range);
Expand Down Expand Up @@ -282,7 +274,7 @@ export const useTimeline = ({ currentTime, range: _range, onClick, onDrag, onPla
}, [currentTime, start, scaleCount, hoursCount, gapHorizontal, scaleInterval, strongScaleHours]);

const events = useTimelineInteraction({ range, zoom, setZoom, gapHorizontal, onClick, onDrag });
const player = useTimelinePlayer({ currentTime, onPlay, range });
const player = useTimelinePlayer({ currentTime, onPlay, onPlayReversed, onSpeedChange });

return {
startDate,
Expand Down
1 change: 0 additions & 1 deletion src/components/atoms/Timeline/index.stories.tsx
Expand Up @@ -36,7 +36,6 @@ export const Movable: Story<Props> = () => {
currentTime={currentTime}
onClick={setCurrentTime}
onDrag={setCurrentTime}
onPlay={setCurrentTime}
isOpened={isOpened}
onOpen={() => setIsOpened(true)}
onClose={() => setIsOpened(false)}
Expand Down
75 changes: 45 additions & 30 deletions src/components/atoms/Timeline/index.test.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
import { expect, test, vi } from "vitest";
import { expect, test, vi, vitest } from "vitest";

import { render, screen, fireEvent, waitFor } from "@reearth/test/utils";
import { render, screen, fireEvent } from "@reearth/test/utils";

import {
PADDING_HORIZONTAL,
Expand All @@ -17,15 +17,22 @@ const CURRENT_TIME = new Date("2022-07-03T00:00:00.000").getTime();
// This is width when range is one day.
const SCROLL_WIDTH = 2208;

const TimelineWrapper: React.FC<{ isOpened?: boolean }> = ({ isOpened = true }) => {
const TimelineWrapper: React.FC<{
isOpened?: boolean;
onPlay?: () => void;
onPlayReversed?: () => void;
onSpeedChange?: (speed: number) => void;
}> = ({ isOpened = true, onPlay, onPlayReversed, onSpeedChange }) => {
const [currentTime, setCurrentTime] = useState(CURRENT_TIME);
return (
<Timeline
currentTime={currentTime}
range={{ start: CURRENT_TIME }}
onClick={setCurrentTime}
onDrag={setCurrentTime}
onPlay={setCurrentTime}
onPlay={onPlay}
onPlayReversed={onPlayReversed}
onSpeedChange={onSpeedChange}
isOpened={isOpened}
/>
);
Expand Down Expand Up @@ -125,39 +132,47 @@ test("it should get correct strongScaleHours from amount of scroll", () => {
).toBe(GAP_HORIZONTAL);
});

test("it should move the knob when play button is clicked and back the move when playback button is clicked", async () => {
render(<TimelineWrapper />);
const initialPosition = 0;
const movedPosition = initialPosition + 1;

// Check initial position
expect(Math.trunc(parseInt(screen.getByTestId("knob-icon").style.left.split("px")[0], 10))).toBe(
initialPosition,
);
test("it should invoke onPlay or onPlayReversed when play button is clicked", async () => {
const mockOnPlay = vitest.fn();
const mockOnPlayReversed = vitest.fn();
render(<TimelineWrapper onPlay={mockOnPlay} onPlayReversed={mockOnPlayReversed} />);

// TODO: get element by label text
// Click play button
fireEvent.click(screen.getAllByRole("button")[2]);

// Check knob move to `expectedLeft`.
await waitFor(
() =>
expect(
Math.trunc(parseInt(screen.getByTestId("knob-icon").style.left.split("px")[0], 10)),
).toBeGreaterThan(movedPosition),
{ timeout: 1500 },
);
expect(mockOnPlay).toBeCalledWith(true);
// Click play button again
fireEvent.click(screen.getAllByRole("button")[2]);
expect(mockOnPlay).toBeCalledWith(false);

// TODO: get element by label text
// Click playback button
fireEvent.click(screen.getAllByRole("button")[1]);
expect(mockOnPlayReversed).toBeCalledWith(true);
// Click playback button again
fireEvent.click(screen.getAllByRole("button")[1]);
expect(mockOnPlayReversed).toBeCalledWith(false);

// Check knob back to initialPosition.
await waitFor(
() =>
expect(
Math.trunc(parseInt(screen.getByTestId("knob-icon").style.left.split("px")[0], 10)),
).toBeLessThan(movedPosition),
{ timeout: 1500 },
);
// Click play button
fireEvent.click(screen.getAllByRole("button")[2]);
expect(mockOnPlay).toBeCalledWith(true);
// And click playback button
fireEvent.click(screen.getAllByRole("button")[1]);
expect(mockOnPlayReversed).toBeCalledWith(true);
expect(mockOnPlay).toBeCalledWith(false);
// Finally click play button
fireEvent.click(screen.getAllByRole("button")[2]);
expect(mockOnPlay).toBeCalledWith(true);
expect(mockOnPlayReversed).toBeCalledWith(false);
});

test("it should invoke onSpeedChange when speed range is changed", async () => {
const mockOnSpeedChange = vitest.fn();
render(<TimelineWrapper onSpeedChange={mockOnSpeedChange} />);

// TODO: get element by label text
// Click play button
fireEvent.input(screen.getAllByRole("slider")[0], { target: { value: 100 } });

expect(mockOnSpeedChange).toBeCalledWith(100);
});
27 changes: 19 additions & 8 deletions src/components/atoms/Timeline/index.tsx
Expand Up @@ -23,11 +23,14 @@ export type Props = {
* These value need to be epoch time.
*/
range?: { [K in keyof Range]?: Range[K] };
speed?: number;
onClick?: TimeEventHandler;
onDrag?: TimeEventHandler;
onPlay?: TimeEventHandler;
onPlay?: (isPlaying: boolean) => void;
onPlayReversed?: (isPlaying: boolean) => void;
onOpen?: () => void;
onClose?: () => void;
onSpeedChange?: (speed: number) => void;
isOpened?: boolean;
sceneProperty?: SceneProperty;
};
Expand All @@ -36,12 +39,15 @@ const Timeline: React.FC<Props> = memo(
function TimelinePresenter({
currentTime,
range,
speed,
onClick,
onDrag,
onPlay,
onPlayReversed,
isOpened,
onOpen,
onClose,
onSpeedChange: onSpeedChangeProps,
sceneProperty,
}) {
const {
Expand All @@ -54,20 +60,21 @@ const Timeline: React.FC<Props> = memo(
currentPosition,
events,
player: {
playSpeed,
onPlaySpeedChange,
formattedCurrentTime,
isPlaying,
isPlayingReversed,
toggleIsPlaying,
toggleIsPlayingReversed,
onSpeedChange,
},
} = useTimeline({
currentTime,
range,
onClick,
onDrag,
onPlay,
onPlayReversed,
onSpeedChange: onSpeedChangeProps,
});
const publishedTheme = usePublishTheme(sceneProperty?.theme);
const t = useT();
Expand Down Expand Up @@ -97,14 +104,14 @@ const Timeline: React.FC<Props> = memo(
</li>
<li>
<InputRangeLabel>
<InputRangeLabelText size="xs">{playSpeed}X</InputRangeLabelText>
<InputRangeLabelText size="xs">{speed}X</InputRangeLabelText>
<InputRange
publishedTheme={publishedTheme}
type="range"
max={10000}
min={1}
value={playSpeed * 10}
onChange={onPlaySpeedChange}
value={speed}
onChange={onSpeedChange}
/>
</InputRangeLabel>
</li>
Expand Down Expand Up @@ -141,7 +148,10 @@ const Timeline: React.FC<Props> = memo(
</OpenButton>
);
},
(prev, next) => prev.currentTime === next.currentTime && prev.isOpened === next.isOpened,
(prev, next) =>
prev.range === next.range &&
prev.currentTime === next.currentTime &&
prev.isOpened === next.isOpened,
);

type StyledColorProps = {
Expand Down Expand Up @@ -259,7 +269,8 @@ const ScaleBox = styled.div`
border-radius: 5px;
background-color: ${({ theme }) => theme.colors.publish.dark.icon.main};
}
margin: ${({ theme }) => `${theme.metrics.s}px 0 ${theme.metrics.s}px ${theme.metrics.xs}px`};
margin: ${({ theme }) =>
`${theme.metrics.s}px ${theme.metrics.m}px ${theme.metrics.s}px ${theme.metrics.xs}px`};
`;

const IconWrapper = styled.div<StyledColorProps>`
Expand Down
Expand Up @@ -28,11 +28,8 @@ export default function ({ isSelected, camera }: { isSelected?: boolean; camera?
exitPhotoOverlay: () => void;
} {
const ctx = useContext();
const flyTo = ctx?.reearth.visualizer.camera.flyTo;
const getCamera = useCallback(
() => ctx?.reearth.visualizer.camera.position,
[ctx?.reearth.visualizer],
);
const flyTo = ctx?.reearth.camera.flyTo;
const getCamera = useCallback(() => ctx?.reearth.camera.position, [ctx?.reearth.camera]);

// mode 0 = idle, 1 = idle<->fly, 2 = fly<->fov, 3 = fov<->photo, 4 = photo
const [mode, prevMode, startTransition] = useDelayedCount(durations);
Expand Down

0 comments on commit f774ee5

Please sign in to comment.