feat(studio): timeline layer UI — context headers, audio lanes, drop affordances

L4 polish for the stacking-layer timeline:
- Sub-composition layers group under a slim "Inside: <comp>" header with a green
  accent, so it reads that restacking there is scoped to that context.
- Audio clips render in a distinct bottom lane: separator border, muted row bg,
  music glyph in the gutter, beat strip — clearly not part of the z-order stack.
- During a vertical drag, a drop affordance shows join-vs-insert: an "onto"
  target highlights the row (join that layer / same z); "between"/"above"/
  "below" shows an insertion line (new layer here).

New pure helper timelineDropIndicator.ts (+ test) maps placement -> indicator;
row-group rendering extracted into TimelineLayerGroupHeader / TimelineLayerGutter
/ TimelineDropInsertionLine / TimelineDragGhost to keep files under the 600 cap.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:26 -04:00
parent 82fe779bd8
commit bc5fc410ee
9 changed files with 623 additions and 276 deletions
@@ -117,6 +117,10 @@ describe("Timeline provider boundary", () => {
);
});
// Flush passive effects (ResizeObserver-driven layout) so the gutter row is
// mounted before we query it.
act(() => {});
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 0"]');
expect(button).not.toBeNull();
if (!button) throw new Error("Expected a track visibility toggle");
@@ -24,6 +24,7 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { ClipContextMenu } from "./ClipContextMenu";
import { TimelineShortcutHint } from "./TimelineShortcutHint";
import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
import { getTimelineLayerGroupHeaderTotalHeight } from "./TimelineLayerGroupHeader";
import {
GUTTER,
generateTicks,
@@ -251,7 +252,9 @@ export const Timeline = memo(function Timeline({
);
}, [draggedClip, trackOrder]);
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
const totalH =
getTimelineCanvasHeight(displayTrackOrder.length) +
getTimelineLayerGroupHeaderTotalHeight(displayTrackOrder, tracks);
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
@@ -1,5 +1,4 @@
import { memo, type ReactNode } from "react";
import { Eye, EyeSlash } from "@phosphor-icons/react";
import { Fragment, memo, type ReactNode } from "react";
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
import { TimelineClip } from "./TimelineClip";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
@@ -25,6 +24,14 @@ import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingA
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { isMusicTrack } from "../../utils/timelineInspector";
import { TimelineLayerGutter } from "./TimelineLayerGutter";
import {
shouldShowTimelineLayerGroupHeader,
TimelineLayerGroupHeader,
} from "./TimelineLayerGroupHeader";
import { resolveTimelineDropIndicator } from "./timelineDropIndicator";
import { TimelineDropInsertionLine } from "./TimelineDropInsertionLine";
import { TimelineDragGhost } from "./TimelineDragGhost";
function ClipLintDot({ element }: { element: TimelineElement }) {
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
@@ -188,6 +195,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
)}
</>
);
const activeDropPlacement =
draggedClip?.started === true ? (draggedClip.previewStackingReorder?.placement ?? null) : null;
return (
<div className="relative" style={{ height: totalH, width: GUTTER + trackContentWidth }}>
@@ -207,11 +216,32 @@ export const TimelineCanvas = memo(function TimelineCanvas({
// fallow-ignore-next-line complexity
displayTrackOrder.map((layerId, rowIndex) => {
const layer = tracks.find((item) => item.id === layerId) ?? null;
const previousLayerId = displayTrackOrder[rowIndex - 1];
const previousLayer = previousLayerId
? (tracks.find((item) => item.id === previousLayerId) ?? null)
: null;
const els = layer?.elements ?? [];
const ts = trackStyles.get(layerId) ?? getTrackStyle("");
const isAudioLayer = layer?.kind === "audio";
const isFirstAudioLayer = isAudioLayer && previousLayer?.kind !== "audio";
const isPendingTrack =
draggedClip?.started === true && !trackOrder.includes(layerId) && els.length === 0;
const rowBackground = rowIndex % 2 === 0 ? theme.rowBackground : "#0D0E12";
const baseRowBackground = rowIndex % 2 === 0 ? theme.rowBackground : "#0D0E12";
const dropIndicator = resolveTimelineDropIndicator({
placement: activeDropPlacement,
layerId,
layerOrder: displayTrackOrder,
});
const rowBackground =
dropIndicator?.kind === "onto"
? theme.clipBackgroundActive
: isAudioLayer
? `linear-gradient(90deg, ${theme.gutterBackground} 0, ${baseRowBackground} 74px)`
: baseRowBackground;
const showGroupHeader = shouldShowTimelineLayerGroupHeader(
layer?.contextKey ?? "",
previousLayer?.contextKey ?? "",
);
const rowTrack = layer?.placementTrack ?? els[0]?.track ?? 0;
// The beat-dot strip occupies the top of this track's lane (active track,
// or the music track when nothing is selected). When shown, keyframe
@@ -223,37 +253,32 @@ export const TimelineCanvas = memo(function TimelineCanvas({
: els.some(isMusicTrack));
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
return (
<div
key={layerId}
className="relative flex"
style={{
height: TRACK_H,
background: rowBackground,
borderBottom: `1px solid ${theme.rowBorder}`,
}}
>
<Fragment key={layerId}>
{showGroupHeader && layer && (
<TimelineLayerGroupHeader
contextKey={layer.contextKey}
trackContentWidth={trackContentWidth}
theme={theme}
accentColor={ts.accent}
/>
)}
<div
className="sticky left-0 z-[12] flex-shrink-0 flex items-center justify-center"
className="relative flex"
style={{
width: GUTTER,
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
height: TRACK_H,
background: rowBackground,
borderTop: isFirstAudioLayer ? `2px solid ${theme.rulerBorder}` : undefined,
borderBottom: `1px solid ${theme.rowBorder}`,
boxShadow:
dropIndicator?.kind === "onto" ? `inset 0 0 0 1px ${ts.accent}` : undefined,
}}
>
<button
type="button"
aria-label={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
title={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
isTrackHidden
? "text-[#3CE6AC] hover:text-white"
: "text-white/35 hover:text-white/75"
}`}
onPointerDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
<TimelineLayerGutter
isAudio={isAudioLayer}
isTrackHidden={isTrackHidden}
rowTrack={rowTrack}
theme={theme}
onToggleHidden={() => {
if (onToggleElementHidden && els.length > 0) {
for (const element of els) {
void onToggleElementHidden(element.key ?? element.id, !isTrackHidden);
@@ -262,274 +287,263 @@ export const TimelineCanvas = memo(function TimelineCanvas({
}
void onToggleTrackHidden?.(rowTrack, !isTrackHidden);
}}
>
{isTrackHidden ? (
<EyeSlash size={14} weight="bold" aria-hidden="true" />
) : (
<Eye size={14} weight="bold" aria-hidden="true" />
)}
</button>
</div>
<div
style={{
width: trackContentWidth,
opacity: isTrackHidden ? 0.35 : 1,
transition: "opacity 120ms ease",
}}
className="relative"
>
{/* Faint beat lines in every track's background (behind the clips);
the active move-snap target is highlighted. */}
<BeatBackgroundLines
beatTimes={beatAnalysis?.beatTimes}
beatStrengths={beatAnalysis?.beatStrengths}
pps={pps}
highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
/>
{/* Beat dots on the active track (the one holding the selection),
falling back to the music track when nothing is selected. */}
{beatStripOnTrack && (
<BeatStrip
<div
style={{
width: trackContentWidth,
opacity: isTrackHidden ? 0.35 : 1,
transition: "opacity 120ms ease",
}}
className="relative"
>
{layer?.contextKey && (
<span
className="absolute bottom-0 top-0 pointer-events-none"
style={{
left: 0,
width: 2,
background: ts.accent,
opacity: 0.45,
zIndex: 2,
}}
/>
)}
{dropIndicator?.kind === "line" && (
<TimelineDropInsertionLine edge={dropIndicator.edge} accentColor={ts.accent} />
)}
{/* Faint beat lines in every track's background (behind the clips);
the active move-snap target is highlighted. */}
<BeatBackgroundLines
beatTimes={beatAnalysis?.beatTimes}
beatStrengths={beatAnalysis?.beatStrengths}
pps={pps}
highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
/>
)}
{isPendingTrack && (
<div
className="absolute inset-0 flex items-center"
style={{
paddingLeft: 16,
color: ts.label,
fontSize: 11,
letterSpacing: "0.06em",
textTransform: "uppercase",
opacity: 0.5,
}}
>
New track
</div>
)}
{
// fallow-ignore-next-line complexity
els.map((el) => {
const clipStyle = getTrackStyle(el.tag);
const elementKey = el.key ?? el.id;
const capabilities = getTimelineEditCapabilities(el);
const isSelected = selectedElementId === elementKey;
const isComposition = !!el.compositionSrc;
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
// fold in the map index, or a splice/reorder remounts every clip
// at/after the change (DOM flash, drag interruption).
const clipKey = elementKey;
const isDraggingClip =
draggedClip?.started === true &&
(draggedElement?.key ?? draggedElement?.id) === elementKey;
if (isDraggingClip) return null;
const previewElement = getPreviewElement(el);
return (
<TimelineClip
key={clipKey}
onContextMenu={(e: React.MouseEvent) => {
e.preventDefault();
onContextMenuClip?.(e, el);
}}
el={previewElement}
pps={pps}
clipY={CLIP_Y}
isSelected={isSelected}
isHovered={hoveredClip === clipKey}
isDragging={false}
hasCustomContent={!!renderClipContent}
capabilities={capabilities}
theme={theme}
isComposition={isComposition}
onHoverStart={() => setHoveredClip(clipKey)}
onHoverEnd={() => setHoveredClip(null)}
onResizeStart={(edge, e) => {
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
if (edge === "start" && !capabilities.canTrimStart) return;
if (edge === "end" && !capabilities.canTrimEnd) return;
e.stopPropagation();
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
setResizingClip({
element: el,
edge,
originClientX: e.clientX,
previewStart: el.start,
previewDuration: el.duration,
previewPlaybackStart: el.playbackStart,
started: false,
});
}}
onPointerDown={
// fallow-ignore-next-line complexity
(e) => {
if (e.button !== 0) return;
if (usePlayerStore.getState().activeTool === "razor") return;
if (e.shiftKey) {
shiftClickClipRef.current = {
element: el,
anchorX: e.clientX,
anchorY: e.clientY,
};
return;
}
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const blockedIntent = resolveBlockedTimelineEditIntent({
width: rect.width,
offsetX: e.clientX - rect.left,
handleWidth: CLIP_HANDLE_W,
capabilities,
});
if (
blockedIntent &&
((blockedIntent === "move" && onMoveElement) ||
(blockedIntent !== "move" && onResizeElement))
) {
blockedClipRef.current = {
element: el,
intent: blockedIntent,
originClientX: e.clientX,
originClientY: e.clientY,
started: false,
};
return;
}
if (!onMoveElement || !capabilities.canMove) return;
{/* Beat dots on the active track (the one holding the selection),
falling back to the music track when nothing is selected. */}
{beatStripOnTrack && (
<BeatStrip
beatTimes={beatAnalysis?.beatTimes}
beatStrengths={beatAnalysis?.beatStrengths}
pps={pps}
/>
)}
{isPendingTrack && (
<div
className="absolute inset-0 flex items-center"
style={{
paddingLeft: 16,
color: ts.label,
fontSize: 11,
letterSpacing: 0,
textTransform: "uppercase",
opacity: 0.5,
}}
>
New track
</div>
)}
{
// fallow-ignore-next-line complexity
els.map((el) => {
const clipStyle = getTrackStyle(el.tag);
const elementKey = el.key ?? el.id;
const capabilities = getTimelineEditCapabilities(el);
const isSelected = selectedElementId === elementKey;
const isComposition = !!el.compositionSrc;
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
// fold in the map index, or a splice/reorder remounts every clip
// at/after the change (DOM flash, drag interruption).
const clipKey = elementKey;
const isDraggingClip =
draggedClip?.started === true &&
(draggedElement?.key ?? draggedElement?.id) === elementKey;
if (isDraggingClip) return null;
const previewElement = getPreviewElement(el);
return (
<TimelineClip
key={clipKey}
onContextMenu={(e: React.MouseEvent) => {
e.preventDefault();
onContextMenuClip?.(e, el);
}}
el={previewElement}
pps={pps}
clipY={CLIP_Y}
isSelected={isSelected}
isHovered={hoveredClip === clipKey}
isDragging={false}
hasCustomContent={!!renderClipContent}
capabilities={capabilities}
theme={theme}
isComposition={isComposition}
onHoverStart={() => setHoveredClip(clipKey)}
onHoverEnd={() => setHoveredClip(null)}
onResizeStart={(edge, e) => {
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
if (edge === "start" && !capabilities.canTrimStart) return;
if (edge === "end" && !capabilities.canTrimEnd) return;
e.stopPropagation();
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
setDraggedClip({
setResizingClip({
element: el,
edge,
originClientX: e.clientX,
originClientY: e.clientY,
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
originScrollTop: scrollRef.current?.scrollTop ?? 0,
pointerClientX: e.clientX,
pointerClientY: e.clientY,
pointerOffsetX: e.clientX - rect.left,
pointerOffsetY: e.clientY - rect.top,
previewStart: el.start,
previewTrack: el.track,
previewLayerId: layerId,
previewLayerIndex: rowIndex,
previewStackingReorder: null,
snapBeatTime: null,
previewDuration: el.duration,
previewPlaybackStart: el.playbackStart,
started: false,
});
syncClipDragAutoScroll(e.clientX, e.clientY);
}
}
onClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
const { activeTool } = usePlayerStore.getState();
if (activeTool === "razor" && onRazorSplit) {
const clipRect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
const clickOffsetX = e.clientX - clipRect.left;
const splitTime = previewElement.start + clickOffsetX / pps;
const clampedTime = Math.max(
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
Math.min(
previewElement.start +
previewElement.duration -
SPLIT_BOUNDARY_EPSILON_S,
splitTime,
),
);
if (e.shiftKey && onRazorSplitAll) {
onRazorSplitAll(clampedTime);
} else {
onRazorSplit(el, clampedTime);
}}
onPointerDown={
// fallow-ignore-next-line complexity
(e) => {
if (e.button !== 0) return;
if (usePlayerStore.getState().activeTool === "razor") return;
if (e.shiftKey) {
shiftClickClipRef.current = {
element: el,
anchorX: e.clientX,
anchorY: e.clientY,
};
return;
}
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const blockedIntent = resolveBlockedTimelineEditIntent({
width: rect.width,
offsetX: e.clientX - rect.left,
handleWidth: CLIP_HANDLE_W,
capabilities,
});
if (
blockedIntent &&
((blockedIntent === "move" && onMoveElement) ||
(blockedIntent !== "move" && onResizeElement))
) {
blockedClipRef.current = {
element: el,
intent: blockedIntent,
originClientX: e.clientX,
originClientY: e.clientY,
started: false,
};
return;
}
if (!onMoveElement || !capabilities.canMove) return;
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
setDraggedClip({
element: el,
originClientX: e.clientX,
originClientY: e.clientY,
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
originScrollTop: scrollRef.current?.scrollTop ?? 0,
pointerClientX: e.clientX,
pointerClientY: e.clientY,
pointerOffsetX: e.clientX - rect.left,
pointerOffsetY: e.clientY - rect.top,
previewStart: el.start,
previewTrack: el.track,
previewLayerId: layerId,
previewLayerIndex: rowIndex,
previewStackingReorder: null,
snapBeatTime: null,
started: false,
});
syncClipDragAutoScroll(e.clientX, e.clientY);
}
return;
}
const nextElement = isSelected ? null : el;
setSelectedElementId(nextElement ? elementKey : null);
onSelectElement?.(nextElement);
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
if (isComposition && onDrillDown) onDrillDown(el);
}}
>
{renderClipChildren(previewElement, clipStyle)}
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
<TimelineClipDiamonds
keyframesData={keyframeCache.get(elementKey)!}
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
clipHeightPx={TRACK_H - 2 * CLIP_Y}
beatsActive={beatStripOnTrack}
beatTimes={beatAnalysis?.beatTimes}
clipStart={previewElement.start}
clipDurationSeconds={previewElement.duration}
pixelsPerSecond={pps}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentPercentage={
previewElement.duration > 0
? ((currentTime - previewElement.start) / previewElement.duration) *
100
: 0
onClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
const { activeTool } = usePlayerStore.getState();
if (activeTool === "razor" && onRazorSplit) {
const clipRect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
const clickOffsetX = e.clientX - clipRect.left;
const splitTime = previewElement.start + clickOffsetX / pps;
const clampedTime = Math.max(
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
Math.min(
previewElement.start +
previewElement.duration -
SPLIT_BOUNDARY_EPSILON_S,
splitTime,
),
);
if (e.shiftKey && onRazorSplitAll) {
onRazorSplitAll(clampedTime);
} else {
onRazorSplit(el, clampedTime);
}
return;
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
suppressClickRef={suppressClickRef}
/>
)}
</TimelineClip>
);
})
}
const nextElement = isSelected ? null : el;
setSelectedElementId(nextElement ? elementKey : null);
onSelectElement?.(nextElement);
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
if (isComposition && onDrillDown) onDrillDown(el);
}}
>
{renderClipChildren(previewElement, clipStyle)}
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
<TimelineClipDiamonds
keyframesData={keyframeCache.get(elementKey)!}
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
clipHeightPx={TRACK_H - 2 * CLIP_Y}
beatsActive={beatStripOnTrack}
beatTimes={beatAnalysis?.beatTimes}
clipStart={previewElement.start}
clipDurationSeconds={previewElement.duration}
pixelsPerSecond={pps}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentPercentage={
previewElement.duration > 0
? ((currentTime - previewElement.start) /
previewElement.duration) *
100
: 0
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
suppressClickRef={suppressClickRef}
/>
)}
</TimelineClip>
);
})
}
</div>
</div>
</div>
</Fragment>
);
})
}
{/* Drag ghost */}
{activeDraggedElement && activeDraggedPosition && (
<div
className="absolute pointer-events-none"
style={{
top: activeDraggedPosition.top,
left: activeDraggedPosition.left,
width: Math.max(activeDraggedElement.duration * pps, 4),
height: TRACK_H - CLIP_Y * 2,
zIndex: 40,
}}
<TimelineDragGhost
element={activeDraggedElement}
position={activeDraggedPosition}
pps={pps}
selectedElementId={selectedElementId}
hasCustomContent={!!renderClipContent}
theme={theme}
>
<TimelineClip
el={{ ...activeDraggedElement, start: 0 }}
pps={pps}
clipY={0}
isSelected={selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)}
isHovered={false}
isDragging={true}
hasCustomContent={!!renderClipContent}
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
theme={theme}
isComposition={!!activeDraggedElement.compositionSrc}
onHoverStart={() => {}}
onHoverEnd={() => {}}
onResizeStart={() => {}}
onClick={() => {}}
onDoubleClick={() => {}}
>
{renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))}
</TimelineClip>
</div>
{renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))}
</TimelineDragGhost>
)}
{/* Range highlight */}
@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { TimelineClip } from "./TimelineClip";
import { getTimelineEditCapabilities } from "./timelineEditing";
import { CLIP_Y, TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
import type { TimelineElement } from "../store/playerStore";
interface TimelineDragGhostProps {
element: TimelineElement;
position: { left: number; top: number };
pps: number;
selectedElementId: string | null;
hasCustomContent: boolean;
theme: TimelineTheme;
children: ReactNode;
}
export function TimelineDragGhost({
element,
position,
pps,
selectedElementId,
hasCustomContent,
theme,
children,
}: TimelineDragGhostProps) {
return (
<div
className="absolute pointer-events-none"
style={{
top: position.top,
left: position.left,
width: Math.max(element.duration * pps, 4),
height: TRACK_H - CLIP_Y * 2,
zIndex: 40,
}}
>
<TimelineClip
el={{ ...element, start: 0 }}
pps={pps}
clipY={0}
isSelected={selectedElementId === (element.key ?? element.id)}
isHovered={false}
isDragging={true}
hasCustomContent={hasCustomContent}
capabilities={getTimelineEditCapabilities(element)}
theme={theme}
isComposition={!!element.compositionSrc}
onHoverStart={() => {}}
onHoverEnd={() => {}}
onResizeStart={() => {}}
onClick={() => {}}
onDoubleClick={() => {}}
>
{children}
</TimelineClip>
</div>
);
}
@@ -0,0 +1,32 @@
interface TimelineDropInsertionLineProps {
edge: "top" | "bottom";
accentColor: string;
}
export function TimelineDropInsertionLine({ edge, accentColor }: TimelineDropInsertionLineProps) {
return (
<div
className="absolute left-0 right-0 pointer-events-none"
style={{
top: edge === "top" ? -1 : undefined,
bottom: edge === "bottom" ? -1 : undefined,
height: 2,
background: accentColor,
boxShadow: `0 0 8px ${accentColor}`,
zIndex: 30,
}}
>
<span
className="absolute rounded-full"
style={{
left: 0,
top: -3,
width: 8,
height: 8,
background: accentColor,
boxShadow: `0 0 8px ${accentColor}`,
}}
/>
</div>
);
}
@@ -0,0 +1,93 @@
import type { TimelineTheme } from "./timelineTheme";
import { GUTTER } from "./timelineLayout";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
const TIMELINE_LAYER_GROUP_HEADER_H = 18;
export function shouldShowTimelineLayerGroupHeader(
contextKey: string,
previousContextKey: string,
): boolean {
return contextKey !== "" && contextKey !== previousContextKey;
}
export function getTimelineLayerGroupHeaderTotalHeight(
layerOrder: readonly TimelineLayerId[],
layers: readonly StackingTimelineLayer[],
): number {
const layerById = new Map<TimelineLayerId, StackingTimelineLayer>();
for (const layer of layers) layerById.set(layer.id, layer);
let previousContextKey = "";
let count = 0;
for (const layerId of layerOrder) {
const contextKey = layerById.get(layerId)?.contextKey ?? "";
if (shouldShowTimelineLayerGroupHeader(contextKey, previousContextKey)) count += 1;
previousContextKey = contextKey;
}
return count * TIMELINE_LAYER_GROUP_HEADER_H;
}
interface TimelineLayerGroupHeaderProps {
contextKey: string;
trackContentWidth: number;
theme: TimelineTheme;
accentColor: string;
}
export function TimelineLayerGroupHeader({
contextKey,
trackContentWidth,
theme,
accentColor,
}: TimelineLayerGroupHeaderProps) {
return (
<div
className="relative"
style={{
height: TIMELINE_LAYER_GROUP_HEADER_H,
width: GUTTER + trackContentWidth,
background: theme.gutterBackground,
borderBottom: `1px solid ${theme.rowBorder}`,
}}
>
<div
className="sticky left-0 z-[13] flex h-full items-center"
style={{
width: Math.min(GUTTER + 220, GUTTER + trackContentWidth),
background: theme.gutterBackground,
}}
>
<div
className="relative h-full flex-shrink-0"
style={{
width: GUTTER,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
>
<span
className="absolute bottom-0 top-0"
style={{
left: 8,
width: 2,
background: accentColor,
opacity: 0.72,
}}
/>
</div>
<div className="min-w-0 px-2">
<span
className="block truncate font-mono uppercase leading-none"
style={{
color: theme.textSecondary,
fontSize: 10,
letterSpacing: 0,
}}
>
Inside: {contextKey}
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import { Eye, EyeSlash } from "@phosphor-icons/react";
import { Music } from "../../icons/SystemIcons";
import type { TimelineTheme } from "./timelineTheme";
import { GUTTER } from "./timelineLayout";
interface TimelineLayerGutterProps {
isAudio: boolean;
isTrackHidden: boolean;
rowTrack: number;
theme: TimelineTheme;
onToggleHidden: () => void;
}
export function TimelineLayerGutter({
isAudio,
isTrackHidden,
rowTrack,
theme,
onToggleHidden,
}: TimelineLayerGutterProps) {
return (
<div
className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
style={{
width: GUTTER,
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
>
{isAudio && (
<Music
size={12}
weight="fill"
aria-hidden="true"
style={{ color: theme.textSecondary, opacity: 0.7 }}
/>
)}
<button
type="button"
aria-label={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
title={isTrackHidden ? `Show track ${rowTrack}` : `Hide track ${rowTrack}`}
className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
isTrackHidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
}`}
onPointerDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
onToggleHidden();
}}
>
{isTrackHidden ? (
<EyeSlash size={14} weight="bold" aria-hidden="true" />
) : (
<Eye size={14} weight="bold" aria-hidden="true" />
)}
</button>
</div>
);
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import type { TimelineLayerDropPlacement } from "./timelineStacking";
import { resolveTimelineDropIndicator } from "./timelineDropIndicator";
const layerOrder = ["front", "middle", "back"];
describe("resolveTimelineDropIndicator", () => {
it("highlights only the row targeted by an onto placement", () => {
const placement: TimelineLayerDropPlacement = { type: "onto", layerId: "middle" };
expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toBeNull();
expect(resolveTimelineDropIndicator({ placement, layerId: "middle", layerOrder })).toEqual({
kind: "onto",
});
});
it("renders a single insertion line at the bottom edge of the before row", () => {
const placement: TimelineLayerDropPlacement = {
type: "between",
beforeLayerId: "front",
afterLayerId: "middle",
};
expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toEqual({
kind: "line",
edge: "bottom",
});
expect(resolveTimelineDropIndicator({ placement, layerId: "middle", layerOrder })).toBeNull();
});
it("renders above placements on the target row top edge", () => {
const placement: TimelineLayerDropPlacement = { type: "above", layerId: "front" };
expect(resolveTimelineDropIndicator({ placement, layerId: "front", layerOrder })).toEqual({
kind: "line",
edge: "top",
});
});
it("renders below placements on the target row bottom edge", () => {
const placement: TimelineLayerDropPlacement = { type: "below", layerId: "back" };
expect(resolveTimelineDropIndicator({ placement, layerId: "back", layerOrder })).toEqual({
kind: "line",
edge: "bottom",
});
});
});
@@ -0,0 +1,33 @@
import type { TimelineLayerDropPlacement } from "./timelineStacking";
import type { TimelineLayerId } from "./timelineTrackOrder";
export type TimelineDropIndicator = { kind: "onto" } | { kind: "line"; edge: "top" | "bottom" };
interface ResolveTimelineDropIndicatorInput {
placement: TimelineLayerDropPlacement | null;
layerId: TimelineLayerId;
layerOrder: readonly TimelineLayerId[];
}
export function resolveTimelineDropIndicator({
placement,
layerId,
layerOrder,
}: ResolveTimelineDropIndicatorInput): TimelineDropIndicator | null {
if (!placement || !layerOrder.includes(layerId)) return null;
switch (placement.type) {
case "onto":
return placement.layerId === layerId ? { kind: "onto" } : null;
case "between":
if (placement.beforeLayerId === layerId) return { kind: "line", edge: "bottom" };
if (!layerOrder.includes(placement.beforeLayerId) && placement.afterLayerId === layerId) {
return { kind: "line", edge: "top" };
}
return null;
case "above":
return placement.layerId === layerId ? { kind: "line", edge: "top" } : null;
case "below":
return placement.layerId === layerId ? { kind: "line", edge: "bottom" } : null;
}
}