mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user