From 4e7f8f9bda04a9e286fa988fb5f5a93ae555599d Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 25 Apr 2026 02:08:03 -0700 Subject: [PATCH] feat(stories): zoom bar bounds tracked to project length, default 60s scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The track editor's zoom was clamped to a hardcoded [10, 200] pixels-per-second range, which had no relationship to the project — on a 4-minute story a "max zoom out" of 200 px/s still required scrolling, and on a 5-second story you could zoom all the way in to where every clip was a tiny sliver. Reframed the bounds in the unit the user actually thinks in: how many seconds of timeline are visible at once. Min scope is 10 s (most zoomed in), max scope is the entire project, and the default lands on a 60 s scope (or the full project, whichever is shorter) once the editor measures its visible track width on first mount. The pixels-per-second value still lives in component state (because every downstream calculation already uses it) but minPps/maxPps are computed from `containerWidth − LABEL_COL_WIDTH` and the project's effective duration, so the +/- buttons and the edge-drag handles on the scrollbar all clamp to bounds that move with the project. Re-clamping fires whenever those bounds shift — adding a long clip or resizing the window pulls the current zoom inside the new range instead of leaving the user parked outside it. --- .../StoriesTab/StoryTrackEditor.tsx | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index ec1656db..60d6fe76 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -129,15 +129,19 @@ const TRACK_HEIGHT = 48; const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px const SCRUB_BAR_HEIGHT = 16; const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px -const MIN_PIXELS_PER_SECOND = 10; -const MAX_PIXELS_PER_SECOND = 200; -const DEFAULT_PIXELS_PER_SECOND = 50; +// Zoom is expressed to the user as how many seconds of timeline are visible +// at once. Min scope = the most you can zoom IN; max scope = the entire +// project. Default scope is what we land on when the editor first measures. +const MIN_VISIBLE_SECONDS = 10; +const DEFAULT_VISIBLE_SECONDS = 60; +const FALLBACK_PIXELS_PER_SECOND = 50; // used until containerWidth is measured const DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks const MIN_EDITOR_HEIGHT = 120; const MAX_EDITOR_HEIGHT = 500; export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { - const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PIXELS_PER_SECOND); + const [pixelsPerSecond, setPixelsPerSecond] = useState(FALLBACK_PIXELS_PER_SECOND); + const hasAppliedDefaultZoomRef = useRef(false); const [draggingItem, setDraggingItem] = useState(null); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); @@ -336,6 +340,41 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000); }, [items, getEffectiveDuration]); + // Zoom bounds are framed in seconds-of-timeline-visible-at-once (the + // "scope") rather than abstract pixels-per-second so the bar reflects + // something meaningful: fully zoomed out shows the entire project, fully + // zoomed in shows MIN_VISIBLE_SECONDS. Convert to pixels using the visible + // track area (container minus the sticky label column). + const visibleTrackWidth = Math.max(0, containerWidth - LABEL_COL_WIDTH); + const projectSeconds = totalDurationMs / 1000; + const { minPps, maxPps } = useMemo(() => { + if (visibleTrackWidth <= 0 || projectSeconds <= 0) { + return { minPps: 10, maxPps: 200 }; + } + const min = visibleTrackWidth / projectSeconds; + const max = visibleTrackWidth / MIN_VISIBLE_SECONDS; + // For projects shorter than MIN_VISIBLE_SECONDS the entire bar collapses + // to one point; clamp so the range stays non-inverted. + return { minPps: min, maxPps: Math.max(max, min) }; + }, [visibleTrackWidth, projectSeconds]); + + // Apply the default scope (60 s, or the whole project if shorter) once we + // have a real measurement to convert it into pixels-per-second. + useEffect(() => { + if (hasAppliedDefaultZoomRef.current) return; + if (visibleTrackWidth <= 0) return; + const defaultScope = Math.min(DEFAULT_VISIBLE_SECONDS, Math.max(projectSeconds, MIN_VISIBLE_SECONDS)); + setPixelsPerSecond(visibleTrackWidth / defaultScope); + hasAppliedDefaultZoomRef.current = true; + }, [visibleTrackWidth, projectSeconds]); + + // Re-clamp the current zoom whenever the bounds shift (project length + // changed, window resized) so the user can't end up parked outside the + // valid range from a previous session. + useEffect(() => { + setPixelsPerSecond((prev) => Math.max(minPps, Math.min(maxPps, prev))); + }, [minPps, maxPps]); + // Calculate timeline width - at least full container width const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding const timelineWidth = Math.max(contentWidth, containerWidth); @@ -367,11 +406,11 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]); const handleZoomIn = () => { - setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND)); + setPixelsPerSecond((prev) => Math.min(prev * 1.5, maxPps)); }; const handleZoomOut = () => { - setPixelsPerSecond((prev) => Math.max(prev / 1.5, MIN_PIXELS_PER_SECOND)); + setPixelsPerSecond((prev) => Math.max(prev / 1.5, minPps)); }; // Resize handlers @@ -903,10 +942,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const newTimelinePx = (containerWidth / newThumbWidth) * scrollbarTrackWidth; const rawPps = (newTimelinePx - 200) / (totalDurationMs / 1000); - const newPps = Math.max( - MIN_PIXELS_PER_SECOND, - Math.min(MAX_PIXELS_PER_SECOND, rawPps), - ); + const newPps = Math.max(minPps, Math.min(maxPps, rawPps)); zoomAnchorRef.current = drag.mode === 'right' @@ -932,7 +968,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; - }, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs]); + }, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs, minPps, maxPps]); if (items.length === 0) { return null; @@ -1304,8 +1340,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { role="slider" aria-label="Zoom from left edge" aria-valuenow={Math.round(pixelsPerSecond)} - aria-valuemin={MIN_PIXELS_PER_SECOND} - aria-valuemax={MAX_PIXELS_PER_SECOND} + aria-valuemin={Math.round(minPps)} + aria-valuemax={Math.round(maxPps)} className="absolute top-0 bottom-0 left-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-l-full" onMouseDown={handleScrollbarMouseDown('left')} /> @@ -1324,8 +1360,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { role="slider" aria-label="Zoom from right edge" aria-valuenow={Math.round(pixelsPerSecond)} - aria-valuemin={MIN_PIXELS_PER_SECOND} - aria-valuemax={MAX_PIXELS_PER_SECOND} + aria-valuemin={Math.round(minPps)} + aria-valuemax={Math.round(maxPps)} className="absolute top-0 bottom-0 right-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-r-full" onMouseDown={handleScrollbarMouseDown('right')} />