diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index f77948fd9..23686c5d8 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -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('button[aria-label="Show track 0"]'); expect(button).not.toBeNull(); if (!button) throw new Error("Expected a track visibility toggle"); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 8a2c526e7..a43b00f86 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -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); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 3d87f7580..0c292abea 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -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 (
@@ -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 ( -
+ + {showGroupHeader && layer && ( + + )}
- -
-
- {/* Faint beat lines in every track's background (behind the clips); - the active move-snap target is highlighted. */} - - {/* Beat dots on the active track (the one holding the selection), - falling back to the music track when nothing is selected. */} - {beatStripOnTrack && ( - + {layer?.contextKey && ( + + )} + {dropIndicator?.kind === "line" && ( + + )} + {/* Faint beat lines in every track's background (behind the clips); + the active move-snap target is highlighted. */} + - )} - {isPendingTrack && ( -
- New track -
- )} - { - // 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 ( - { - 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 && ( + + )} + {isPendingTrack && ( +
+ New track +
+ )} + { + // 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 ( + { + 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) && ( - 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} - /> - )} - - ); - }) - } + 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) && ( + 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} + /> + )} +
+ ); + }) + } +
-
+ ); }) } - {/* Drag ghost */} {activeDraggedElement && activeDraggedPosition && ( -
- {}} - onHoverEnd={() => {}} - onResizeStart={() => {}} - onClick={() => {}} - onDoubleClick={() => {}} - > - {renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))} - -
+ {renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))} + )} {/* Range highlight */} diff --git a/packages/studio/src/player/components/TimelineDragGhost.tsx b/packages/studio/src/player/components/TimelineDragGhost.tsx new file mode 100644 index 000000000..771bddb65 --- /dev/null +++ b/packages/studio/src/player/components/TimelineDragGhost.tsx @@ -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 ( +
+ {}} + onHoverEnd={() => {}} + onResizeStart={() => {}} + onClick={() => {}} + onDoubleClick={() => {}} + > + {children} + +
+ ); +} diff --git a/packages/studio/src/player/components/TimelineDropInsertionLine.tsx b/packages/studio/src/player/components/TimelineDropInsertionLine.tsx new file mode 100644 index 000000000..8ca3414ea --- /dev/null +++ b/packages/studio/src/player/components/TimelineDropInsertionLine.tsx @@ -0,0 +1,32 @@ +interface TimelineDropInsertionLineProps { + edge: "top" | "bottom"; + accentColor: string; +} + +export function TimelineDropInsertionLine({ edge, accentColor }: TimelineDropInsertionLineProps) { + return ( +
+ +
+ ); +} diff --git a/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx b/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx new file mode 100644 index 000000000..6f1a40753 --- /dev/null +++ b/packages/studio/src/player/components/TimelineLayerGroupHeader.tsx @@ -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(); + 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 ( +
+
+
+ +
+
+ + Inside: {contextKey} + +
+
+
+ ); +} diff --git a/packages/studio/src/player/components/TimelineLayerGutter.tsx b/packages/studio/src/player/components/TimelineLayerGutter.tsx new file mode 100644 index 000000000..435df303f --- /dev/null +++ b/packages/studio/src/player/components/TimelineLayerGutter.tsx @@ -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 ( +
+ {isAudio && ( +
+ ); +} diff --git a/packages/studio/src/player/components/timelineDropIndicator.test.ts b/packages/studio/src/player/components/timelineDropIndicator.test.ts new file mode 100644 index 000000000..48b98a787 --- /dev/null +++ b/packages/studio/src/player/components/timelineDropIndicator.test.ts @@ -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", + }); + }); +}); diff --git a/packages/studio/src/player/components/timelineDropIndicator.ts b/packages/studio/src/player/components/timelineDropIndicator.ts new file mode 100644 index 000000000..011d05fc3 --- /dev/null +++ b/packages/studio/src/player/components/timelineDropIndicator.ts @@ -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; + } +}