mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-17 05:40:42 -07:00
feat(stories): zoom bar bounds tracked to project length, default 60s scope
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.
This commit is contained in:
@@ -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<string | null>(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')}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user