mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -07:00
feat(stories): add empty tracks above/below the timeline
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.
This commit is contained in:
@@ -236,6 +236,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const updateVolume = useUpdateStoryItemVolume();
|
const updateVolume = useUpdateStoryItemVolume();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
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<number[]>([]);
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||||
@@ -344,10 +348,32 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
stop();
|
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 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
|
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]);
|
}, [items]);
|
||||||
|
|
||||||
// Track container width for full-width minimum
|
// 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 */}
|
{/* Per-track rows: label and background as flex siblings guarantee alignment */}
|
||||||
{tracks.map((trackNumber, index) => (
|
{tracks.map((trackNumber, index) => {
|
||||||
<div
|
const isFirst = index === 0;
|
||||||
key={trackNumber}
|
const isLast = index === tracks.length - 1;
|
||||||
className="absolute left-0 right-0 flex"
|
return (
|
||||||
style={{
|
|
||||||
top: `${index * TRACK_HEIGHT}px`,
|
|
||||||
height: `${TRACK_HEIGHT}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="w-16 shrink-0 border-b border-r flex items-center justify-center sticky left-0 z-20 h-full bg-background">
|
|
||||||
<div className="absolute inset-0 bg-muted/20 pointer-events-none" />
|
|
||||||
<span className="relative text-[10px] text-muted-foreground select-none">
|
|
||||||
{trackNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
key={trackNumber}
|
||||||
'border-b flex-1 pointer-events-none',
|
className="absolute left-0 right-0 flex"
|
||||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
style={{
|
||||||
)}
|
top: `${index * TRACK_HEIGHT}px`,
|
||||||
/>
|
height: `${TRACK_HEIGHT}px`,
|
||||||
</div>
|
}}
|
||||||
))}
|
>
|
||||||
|
<div className="w-16 shrink-0 border-b border-r flex items-center justify-center sticky left-0 z-20 h-full bg-background">
|
||||||
|
<div className="absolute inset-0 bg-muted/20 pointer-events-none" />
|
||||||
|
<span className="relative text-[10px] text-muted-foreground select-none">
|
||||||
|
{trackNumber}
|
||||||
|
</span>
|
||||||
|
{isFirst && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddTrackAbove}
|
||||||
|
title="Add track above"
|
||||||
|
aria-label="Add track above"
|
||||||
|
className="absolute top-0 right-0 left-0 h-3 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isLast && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddTrackBelow}
|
||||||
|
title="Add track below"
|
||||||
|
aria-label="Add track below"
|
||||||
|
className="absolute bottom-0 right-0 left-0 h-3 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-b flex-1 pointer-events-none',
|
||||||
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Clip/playhead/seek layer offset past the label column */}
|
{/* Clip/playhead/seek layer offset past the label column */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user