mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -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 TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px
|
||||||
const SCRUB_BAR_HEIGHT = 16;
|
const SCRUB_BAR_HEIGHT = 16;
|
||||||
const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px
|
const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px
|
||||||
const MIN_PIXELS_PER_SECOND = 10;
|
// Zoom is expressed to the user as how many seconds of timeline are visible
|
||||||
const MAX_PIXELS_PER_SECOND = 200;
|
// at once. Min scope = the most you can zoom IN; max scope = the entire
|
||||||
const DEFAULT_PIXELS_PER_SECOND = 50;
|
// 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 DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks
|
||||||
const MIN_EDITOR_HEIGHT = 120;
|
const MIN_EDITOR_HEIGHT = 120;
|
||||||
const MAX_EDITOR_HEIGHT = 500;
|
const MAX_EDITOR_HEIGHT = 500;
|
||||||
|
|
||||||
export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
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 [draggingItem, setDraggingItem] = useState<string | null>(null);
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const [dragPosition, setDragPosition] = 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);
|
return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000);
|
||||||
}, [items, getEffectiveDuration]);
|
}, [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
|
// Calculate timeline width - at least full container width
|
||||||
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
|
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
|
||||||
const timelineWidth = Math.max(contentWidth, containerWidth);
|
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 pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]);
|
||||||
|
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
|
setPixelsPerSecond((prev) => Math.min(prev * 1.5, maxPps));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleZoomOut = () => {
|
const handleZoomOut = () => {
|
||||||
setPixelsPerSecond((prev) => Math.max(prev / 1.5, MIN_PIXELS_PER_SECOND));
|
setPixelsPerSecond((prev) => Math.max(prev / 1.5, minPps));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resize handlers
|
// Resize handlers
|
||||||
@@ -903,10 +942,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
|
|
||||||
const newTimelinePx = (containerWidth / newThumbWidth) * scrollbarTrackWidth;
|
const newTimelinePx = (containerWidth / newThumbWidth) * scrollbarTrackWidth;
|
||||||
const rawPps = (newTimelinePx - 200) / (totalDurationMs / 1000);
|
const rawPps = (newTimelinePx - 200) / (totalDurationMs / 1000);
|
||||||
const newPps = Math.max(
|
const newPps = Math.max(minPps, Math.min(maxPps, rawPps));
|
||||||
MIN_PIXELS_PER_SECOND,
|
|
||||||
Math.min(MAX_PIXELS_PER_SECOND, rawPps),
|
|
||||||
);
|
|
||||||
|
|
||||||
zoomAnchorRef.current =
|
zoomAnchorRef.current =
|
||||||
drag.mode === 'right'
|
drag.mode === 'right'
|
||||||
@@ -932,7 +968,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
window.removeEventListener('mousemove', onMouseMove);
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
window.removeEventListener('mouseup', onMouseUp);
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
};
|
};
|
||||||
}, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs]);
|
}, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs, minPps, maxPps]);
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1304,8 +1340,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
role="slider"
|
role="slider"
|
||||||
aria-label="Zoom from left edge"
|
aria-label="Zoom from left edge"
|
||||||
aria-valuenow={Math.round(pixelsPerSecond)}
|
aria-valuenow={Math.round(pixelsPerSecond)}
|
||||||
aria-valuemin={MIN_PIXELS_PER_SECOND}
|
aria-valuemin={Math.round(minPps)}
|
||||||
aria-valuemax={MAX_PIXELS_PER_SECOND}
|
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"
|
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')}
|
onMouseDown={handleScrollbarMouseDown('left')}
|
||||||
/>
|
/>
|
||||||
@@ -1324,8 +1360,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
role="slider"
|
role="slider"
|
||||||
aria-label="Zoom from right edge"
|
aria-label="Zoom from right edge"
|
||||||
aria-valuenow={Math.round(pixelsPerSecond)}
|
aria-valuenow={Math.round(pixelsPerSecond)}
|
||||||
aria-valuemin={MIN_PIXELS_PER_SECOND}
|
aria-valuemin={Math.round(minPps)}
|
||||||
aria-valuemax={MAX_PIXELS_PER_SECOND}
|
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"
|
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')}
|
onMouseDown={handleScrollbarMouseDown('right')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user