mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'timeline-zoom'
This commit is contained in:
@@ -423,7 +423,11 @@ export function Timeline() {
|
|||||||
onResizeStateChange={handleResizeStateChange}
|
onResizeStateChange={handleResizeStateChange}
|
||||||
onElementMouseDown={handleElementMouseDown}
|
onElementMouseDown={handleElementMouseDown}
|
||||||
onElementClick={handleElementClick}
|
onElementClick={handleElementClick}
|
||||||
onTrackMouseDown={handleSelectionMouseDown}
|
onTrackMouseDown={(event) => {
|
||||||
|
handleSelectionMouseDown(event);
|
||||||
|
handleTracksMouseDown(event);
|
||||||
|
}}
|
||||||
|
onTrackClick={handleTracksClick}
|
||||||
shouldIgnoreClick={shouldIgnoreClick}
|
shouldIgnoreClick={shouldIgnoreClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { formatTimeCode } from "@/lib/time";
|
import { formatTimeCode } from "@/lib/time";
|
||||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
|
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||||
import { EditableTimecode } from "@/components/editable-timecode";
|
import { EditableTimecode } from "@/components/editable-timecode";
|
||||||
import { ScenesView } from "../scenes-view";
|
import { ScenesView } from "../scenes-view";
|
||||||
import { type TAction, invokeAction } from "@/lib/actions";
|
import { type TAction, invokeAction } from "@/lib/actions";
|
||||||
@@ -54,9 +55,9 @@ export function TimelineToolbar({
|
|||||||
direction === "in"
|
direction === "in"
|
||||||
? Math.min(
|
? Math.min(
|
||||||
TIMELINE_CONSTANTS.ZOOM_MAX,
|
TIMELINE_CONSTANTS.ZOOM_MAX,
|
||||||
zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP,
|
zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
|
||||||
)
|
)
|
||||||
: Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP);
|
: Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
|
||||||
setZoomLevel({ zoom: newZoomLevel });
|
setZoomLevel({ zoom: newZoomLevel });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,17 +128,13 @@ function ToolbarLeftSection() {
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||||
tooltip="Split element"
|
tooltip="Split element"
|
||||||
onClick={({ event }) =>
|
onClick={({ event }) => handleAction({ action: "split", event })}
|
||||||
handleAction({ action: "split", event })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
|
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
|
||||||
tooltip="Split left"
|
tooltip="Split left"
|
||||||
onClick={({ event }) =>
|
onClick={({ event }) => handleAction({ action: "split-left", event })}
|
||||||
handleAction({ action: "split-left", event })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
@@ -302,15 +299,17 @@ function ToolbarRightSection({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onZoom({ direction: "out" })}
|
onClick={() => onZoom({ direction: "out" })}
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={SearchAddIcon} />
|
<HugeiconsIcon icon={SearchMinusIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
<Slider
|
<Slider
|
||||||
className="w-24"
|
className="w-28"
|
||||||
value={[zoomLevel]}
|
value={[zoomToSlider({ zoomLevel, minZoom })]}
|
||||||
onValueChange={(values) => onZoomChange(values[0])}
|
onValueChange={(values) =>
|
||||||
min={minZoom}
|
onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
|
||||||
max={TIMELINE_CONSTANTS.ZOOM_MAX}
|
}
|
||||||
step={TIMELINE_CONSTANTS.ZOOM_STEP}
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.005}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -318,7 +317,7 @@ function ToolbarRightSection({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onZoom({ direction: "in" })}
|
onClick={() => onZoom({ direction: "in" })}
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={SearchMinusIcon} />
|
<HugeiconsIcon icon={SearchAddIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface TimelineTrackContentProps {
|
|||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
}) => void;
|
}) => void;
|
||||||
onTrackMouseDown?: (event: React.MouseEvent) => void;
|
onTrackMouseDown?: (event: React.MouseEvent) => void;
|
||||||
|
onTrackClick?: (event: React.MouseEvent) => void;
|
||||||
shouldIgnoreClick?: () => boolean;
|
shouldIgnoreClick?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ export function TimelineTrackContent({
|
|||||||
onElementMouseDown,
|
onElementMouseDown,
|
||||||
onElementClick,
|
onElementClick,
|
||||||
onTrackMouseDown,
|
onTrackMouseDown,
|
||||||
|
onTrackClick,
|
||||||
shouldIgnoreClick,
|
shouldIgnoreClick,
|
||||||
}: TimelineTrackContentProps) {
|
}: TimelineTrackContentProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
@@ -68,11 +70,13 @@ export function TimelineTrackContent({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
|
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
if (shouldIgnoreClick?.()) return;
|
if (shouldIgnoreClick?.()) return;
|
||||||
clearElementSelection();
|
clearElementSelection();
|
||||||
|
onTrackClick?.(event);
|
||||||
}}
|
}}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
onTrackMouseDown?.(event);
|
onTrackMouseDown?.(event);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -35,12 +35,11 @@ export const TRACK_GAP = 4;
|
|||||||
export const TIMELINE_CONSTANTS = {
|
export const TIMELINE_CONSTANTS = {
|
||||||
PIXELS_PER_SECOND: 50,
|
PIXELS_PER_SECOND: 50,
|
||||||
DEFAULT_ELEMENT_DURATION: 5,
|
DEFAULT_ELEMENT_DURATION: 5,
|
||||||
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead
|
|
||||||
PADDING_TOP_PX: 0,
|
PADDING_TOP_PX: 0,
|
||||||
ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 15, 20, 30, 50],
|
|
||||||
ZOOM_MIN: 0.1,
|
ZOOM_MIN: 0.1,
|
||||||
ZOOM_MAX: 100,
|
ZOOM_MAX: 100,
|
||||||
ZOOM_STEP: 0.1,
|
ZOOM_BUTTON_FACTOR: 1.7,
|
||||||
|
ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
|
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||||
|
|
||||||
interface UseTimelineZoomProps {
|
interface UseTimelineZoomProps {
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
@@ -122,18 +123,47 @@ export function useTimelineZoom({
|
|||||||
if (previousZoom === zoomLevel) return;
|
if (previousZoom === zoomLevel) return;
|
||||||
|
|
||||||
const scrollElement = tracksScrollRef.current;
|
const scrollElement = tracksScrollRef.current;
|
||||||
if (scrollElement) {
|
if (!scrollElement) {
|
||||||
editor.project.setTimelineViewState({
|
previousZoomRef.current = zoomLevel;
|
||||||
viewState: {
|
return;
|
||||||
zoomLevel,
|
}
|
||||||
scrollLeft: scrollElement.scrollLeft,
|
|
||||||
playheadTime: editor.playback.getCurrentTime(),
|
const currentScrollLeft = scrollElement.scrollLeft;
|
||||||
},
|
const playheadTime = editor.playback.getCurrentTime();
|
||||||
});
|
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
|
||||||
|
|
||||||
|
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||||
|
const playheadPixelsBefore =
|
||||||
|
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom;
|
||||||
|
const playheadPixelsAfter =
|
||||||
|
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||||
|
|
||||||
|
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
||||||
|
const newScrollLeft = playheadPixelsAfter - viewportOffset;
|
||||||
|
|
||||||
|
const maxScrollLeft =
|
||||||
|
scrollElement.scrollWidth - scrollElement.clientWidth;
|
||||||
|
const clampedScrollLeft = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(maxScrollLeft, newScrollLeft),
|
||||||
|
);
|
||||||
|
|
||||||
|
scrollElement.scrollLeft = clampedScrollLeft;
|
||||||
|
if (rulerScrollRef.current) {
|
||||||
|
rulerScrollRef.current.scrollLeft = clampedScrollLeft;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
previousZoomRef.current = zoomLevel;
|
previousZoomRef.current = zoomLevel;
|
||||||
}, [zoomLevel, editor, tracksScrollRef]);
|
|
||||||
|
editor.project.setTimelineViewState({
|
||||||
|
viewState: {
|
||||||
|
zoomLevel,
|
||||||
|
scrollLeft: scrollElement.scrollLeft,
|
||||||
|
playheadTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]);
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
|
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
|
||||||
const saveScrollPosition = useCallback(() => {
|
const saveScrollPosition = useCallback(() => {
|
||||||
|
|||||||
@@ -51,3 +51,36 @@ export function getZoomPercent({
|
|||||||
}): number {
|
}): number {
|
||||||
return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
|
return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* convert linear slider position (0-1) to exponential zoom level.
|
||||||
|
* at low slider values, zoom changes are small. at high values, changes are large.
|
||||||
|
*/
|
||||||
|
export function sliderToZoom({
|
||||||
|
sliderPosition,
|
||||||
|
minZoom,
|
||||||
|
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
|
||||||
|
}: {
|
||||||
|
sliderPosition: number;
|
||||||
|
minZoom: number;
|
||||||
|
maxZoom?: number;
|
||||||
|
}): number {
|
||||||
|
const clampedPosition = Math.max(0, Math.min(1, sliderPosition));
|
||||||
|
return minZoom * (maxZoom / minZoom) ** clampedPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* convert exponential zoom level to linear slider position (0-1).
|
||||||
|
*/
|
||||||
|
export function zoomToSlider({
|
||||||
|
zoomLevel,
|
||||||
|
minZoom,
|
||||||
|
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
|
||||||
|
}: {
|
||||||
|
zoomLevel: number;
|
||||||
|
minZoom: number;
|
||||||
|
maxZoom?: number;
|
||||||
|
}): number {
|
||||||
|
const clampedZoom = Math.max(minZoom, Math.min(maxZoom, zoomLevel));
|
||||||
|
return Math.log(clampedZoom / minZoom) / Math.log(maxZoom / minZoom);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user