From c7f50d566841236476a185ff6d00e035e28f9a9b Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 25 Apr 2026 04:27:23 -0700 Subject: [PATCH] feat(stories): add empty tracks above/below the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiny + strips sit at the top of the topmost label cell and the bottom of the bottommost one, sticky-positioned in the label column so they follow horizontal scroll. Clicking either extends the visible track stack in that direction by one — above adds max(existing)+1, below adds min(existing)-1. Both compute against the full set (defaults + item-derived + previously-added) so successive clicks keep extending instead of fighting over the same number. Empty extras live in component state because a track only earns its keep once a clip lands on it. Once one does, item.track carries the number forward and the row keeps deriving from items naturally; if nothing lands there before reload, the empty row simply isn't there next time, which matches what the user expects of an unused affordance. --- .../StoriesTab/StoryTrackEditor.tsx | 100 +++++++++++++----- 1 file changed, 76 insertions(+), 24 deletions(-) diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index d96830b8..1b945d15 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -236,6 +236,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const updateVolume = useUpdateStoryItemVolume(); const { toast } = useToast(); const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration); + // User-added empty tracks. Live in component state because a track only + // earns its keep once a clip lands on it — no need to persist an unused + // row across reloads. + const [extraTracks, setExtraTracks] = useState([]); // Selection state const selectedClipId = useStoryStore((state) => state.selectedClipId); @@ -344,10 +348,32 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { stop(); }; - // Calculate unique tracks from items, always showing at least 3 default tracks + // Calculate unique tracks from items, always showing at least 3 default + // tracks. ``extraTracks`` lets the user open a fresh row without first + // having to drag a clip there. const tracks = useMemo(() => { - const trackSet = new Set([...DEFAULT_TRACKS, ...items.map((item) => item.track)]); + const trackSet = new Set([ + ...DEFAULT_TRACKS, + ...items.map((item) => item.track), + ...extraTracks, + ]); return Array.from(trackSet).sort((a, b) => b - a); // Higher tracks on top + }, [items, extraTracks]); + + const handleAddTrackAbove = useCallback(() => { + setExtraTracks((prev) => { + const all = new Set([...DEFAULT_TRACKS, ...items.map((i) => i.track), ...prev]); + const next = (all.size > 0 ? Math.max(...all) : 0) + 1; + return [...prev, next]; + }); + }, [items]); + + const handleAddTrackBelow = useCallback(() => { + setExtraTracks((prev) => { + const all = new Set([...DEFAULT_TRACKS, ...items.map((i) => i.track), ...prev]); + const next = (all.size > 0 ? Math.min(...all) : 0) - 1; + return [...prev, next]; + }); }, [items]); // Track container width for full-width minimum @@ -1286,29 +1312,55 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { }} > {/* Per-track rows: label and background as flex siblings guarantee alignment */} - {tracks.map((trackNumber, index) => ( -
-
-
- - {trackNumber} - -
+ {tracks.map((trackNumber, index) => { + const isFirst = index === 0; + const isLast = index === tracks.length - 1; + return (
-
- ))} + key={trackNumber} + className="absolute left-0 right-0 flex" + style={{ + top: `${index * TRACK_HEIGHT}px`, + height: `${TRACK_HEIGHT}px`, + }} + > +
+
+ + {trackNumber} + + {isFirst && ( + + )} + {isLast && ( + + )} +
+
+
+ ); + })} {/* Clip/playhead/seek layer offset past the label column */}