summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx6
-rw-r--r--apps/web/src/components/editor/timeline/timeline-toolbar.tsx31
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx6
-rw-r--r--apps/web/src/constants/timeline-constants.tsx5
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts48
-rw-r--r--apps/web/src/lib/timeline/zoom-utils.ts33
6 files changed, 99 insertions, 30 deletions
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index 7e2b2b66..271bad9b 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -423,7 +423,11 @@ export function Timeline() {
onResizeStateChange={handleResizeStateChange}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
- onTrackMouseDown={handleSelectionMouseDown}
+ onTrackMouseDown={(event) => {
+ handleSelectionMouseDown(event);
+ handleTracksMouseDown(event);
+ }}
+ onTrackClick={handleTracksClick}
shouldIgnoreClick={shouldIgnoreClick}
/>
</div>
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
index 822bb438..6ac6fba5 100644
--- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
@@ -16,6 +16,7 @@ import {
import { Slider } from "@/components/ui/slider";
import { formatTimeCode } from "@/lib/time";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { EditableTimecode } from "@/components/editable-timecode";
import { ScenesView } from "../scenes-view";
import { type TAction, invokeAction } from "@/lib/actions";
@@ -54,9 +55,9 @@ export function TimelineToolbar({
direction === "in"
? Math.min(
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 });
};
@@ -127,17 +128,13 @@ function ToolbarLeftSection() {
<ToolbarButton
icon={<HugeiconsIcon icon={ScissorIcon} />}
tooltip="Split element"
- onClick={({ event }) =>
- handleAction({ action: "split", event })
- }
+ onClick={({ event }) => handleAction({ action: "split", event })}
/>
<ToolbarButton
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
tooltip="Split left"
- onClick={({ event }) =>
- handleAction({ action: "split-left", event })
- }
+ onClick={({ event }) => handleAction({ action: "split-left", event })}
/>
<ToolbarButton
@@ -302,15 +299,17 @@ function ToolbarRightSection({
type="button"
onClick={() => onZoom({ direction: "out" })}
>
- <HugeiconsIcon icon={SearchAddIcon} />
+ <HugeiconsIcon icon={SearchMinusIcon} />
</Button>
<Slider
- className="w-24"
- value={[zoomLevel]}
- onValueChange={(values) => onZoomChange(values[0])}
- min={minZoom}
- max={TIMELINE_CONSTANTS.ZOOM_MAX}
- step={TIMELINE_CONSTANTS.ZOOM_STEP}
+ className="w-28"
+ value={[zoomToSlider({ zoomLevel, minZoom })]}
+ onValueChange={(values) =>
+ onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
+ }
+ min={0}
+ max={1}
+ step={0.005}
/>
<Button
variant="text"
@@ -318,7 +317,7 @@ function ToolbarRightSection({
type="button"
onClick={() => onZoom({ direction: "in" })}
>
- <HugeiconsIcon icon={SearchMinusIcon} />
+ <HugeiconsIcon icon={SearchAddIcon} />
</Button>
</div>
</div>
diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx
index 573bd4b5..47a9f282 100644
--- a/apps/web/src/components/editor/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-track.tsx
@@ -31,6 +31,7 @@ interface TimelineTrackContentProps {
track: TimelineTrack;
}) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void;
+ onTrackClick?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
}
@@ -46,6 +47,7 @@ export function TimelineTrackContent({
onElementMouseDown,
onElementClick,
onTrackMouseDown,
+ onTrackClick,
shouldIgnoreClick,
}: TimelineTrackContentProps) {
const editor = useEditor();
@@ -68,11 +70,13 @@ export function TimelineTrackContent({
return (
<button
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
- onClick={() => {
+ onClick={(event) => {
if (shouldIgnoreClick?.()) return;
clearElementSelection();
+ onTrackClick?.(event);
}}
onMouseDown={(event) => {
+ event.preventDefault();
onTrackMouseDown?.(event);
}}
type="button"
diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx
index 692ccd65..d6af3c3a 100644
--- a/apps/web/src/constants/timeline-constants.tsx
+++ b/apps/web/src/constants/timeline-constants.tsx
@@ -35,12 +35,11 @@ export const TRACK_GAP = 4;
export const TIMELINE_CONSTANTS = {
PIXELS_PER_SECOND: 50,
DEFAULT_ELEMENT_DURATION: 5,
- PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead
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_MAX: 100,
- ZOOM_STEP: 0.1,
+ ZOOM_BUTTON_FACTOR: 1.7,
+ ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15,
} as const;
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
index 3d15b5c8..13024103 100644
--- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
@@ -9,6 +9,7 @@ import {
} from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEditor } from "@/hooks/use-editor";
+import { zoomToSlider } from "@/lib/timeline/zoom-utils";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement | null>;
@@ -122,18 +123,47 @@ export function useTimelineZoom({
if (previousZoom === zoomLevel) return;
const scrollElement = tracksScrollRef.current;
- if (scrollElement) {
- editor.project.setTimelineViewState({
- viewState: {
- zoomLevel,
- scrollLeft: scrollElement.scrollLeft,
- playheadTime: editor.playback.getCurrentTime(),
- },
- });
+ if (!scrollElement) {
+ previousZoomRef.current = zoomLevel;
+ return;
+ }
+
+ 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;
- }, [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
const saveScrollPosition = useCallback(() => {
diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts
index 49f7fcb6..bbc0b6ae 100644
--- a/apps/web/src/lib/timeline/zoom-utils.ts
+++ b/apps/web/src/lib/timeline/zoom-utils.ts
@@ -51,3 +51,36 @@ export function getZoomPercent({
}): number {
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);
+}