Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 64 additions & 6 deletions web/src/components/timeline/TimelineContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
dropTargetAt,
frameAt,
totalFrames,
trackAt,
} from "../../lib/geometry";
import { gapAtFrame } from "../../lib/timelineGap";
import { firstAudioIndex } from "../../lib/zones";
import { clampTrimDeltaFrames, trimSourceValues } from "../../lib/clip";
import { collectTargets, findSnap, findSnapDelta } from "../../lib/snap";
Expand Down Expand Up @@ -320,6 +322,9 @@ export function TimelineContainer() {
const selectedClipIds = useEditorUiStore((s) => s.selectedClipIds);
const selectClips = useEditorUiStore((s) => s.selectClips);
const clearSelection = useEditorUiStore((s) => s.clearSelection);
const selectedTimelineRange = useEditorUiStore((s) => s.selectedTimelineRange);
const selectedGap = useEditorUiStore((s) => s.selectedGap);
const selectGap = useEditorUiStore((s) => s.selectGap);
const trackHeights = useEditorUiStore((s) => s.trackDisplayHeights);
const mediaItems = useMediaStore((s) => s.items);

Expand Down Expand Up @@ -539,12 +544,16 @@ export function TimelineContainer() {
emptyLabel: t("timeline.dropHint"),
drag,
mediaGhost: mediaGhostRef.current ?? undefined,
selectedRange: selectedTimelineRange,
selectedGap,
});
}, [
timeline,
zoomScale,
trackHeights,
selectedClipIds,
selectedTimelineRange,
selectedGap,
scrollLeft,
scrollTop,
viewport,
Expand Down Expand Up @@ -699,8 +708,15 @@ export function TimelineContainer() {
canvas.style.height = `${LAYOUT.rulerHeight}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
paintRuler(ctx, { fps: timeline.fps, pixelsPerFrame: zoomScale, scrollLeft, width: viewport.width, dpr });
}, [timeline.fps, zoomScale, scrollLeft, viewport.width]);
paintRuler(ctx, {
fps: timeline.fps,
pixelsPerFrame: zoomScale,
scrollLeft,
width: viewport.width,
dpr,
selectedRange: selectedTimelineRange,
});
}, [timeline.fps, zoomScale, scrollLeft, viewport.width, selectedTimelineRange]);

// --- Coordinate helpers (event -> document space) ---
const toDoc = useCallback(
Expand Down Expand Up @@ -917,8 +933,16 @@ export function TimelineContainer() {
return;
}

// Empty space -> clear selection (non-shift) + start marquee.
if (!e.shiftKey) clearSelection();
// Empty space -> clear selection (non-shift) + start marquee. If the click
// lands in an empty gap between clips, select that gap (upstream sets
// `selectedGap = hitTestGap(...)` here; gap & clip selection are mutually
// exclusive). A gap is only selectable when NOT shift-extending a marquee.
if (!e.shiftKey) {
clearSelection();
const ti = trackAt(timeline, docY, trackHeights);
const gap = ti !== null ? gapAtFrame(timeline, ti, frameAt(docX, zoomScale)) : null;
selectGap(gap); // null clears any prior gap; a hit selects it
}
dragRef.current = {
kind: "marquee",
startDocX: docX,
Expand All @@ -927,7 +951,7 @@ export function TimelineContainer() {
curDocY: docY,
};
},
[toDoc, timeline, zoomScale, trackHeights, toolMode, selectedClipIds, selectClips, clearSelection, setCurrentFrame, setScrubbing],
[toDoc, timeline, zoomScale, trackHeights, toolMode, selectedClipIds, selectClips, clearSelection, selectGap, setCurrentFrame, setScrubbing],
);

const onPointerMove = useCallback(
Expand Down Expand Up @@ -1386,11 +1410,20 @@ export function TimelineContainer() {
startFrame,
dropTargetAt(timeline, docY, trackHeights),
);
// ⌘/Ctrl held (and landing on an existing track, not a new-track insert or
// a trimmed moment drag) → preview a ripple insert (upstream shows
// `drawRippleInsertIndicator`). Otherwise the plain overwrite ghost.
const rippleInsert =
(e.ctrlKey || e.metaKey) &&
!momentRange &&
resolved.trackIndex !== null &&
resolved.newTrack === null;
const next: MediaGhostPaint = {
startFrame,
durationFrames,
trackIndex: resolved.trackIndex,
newTrackIndex: resolved.newTrack ? resolved.newTrack.index : null,
rippleInsert,
};
const prev = mediaGhostRef.current;
mediaGhostRef.current = next;
Expand All @@ -1402,7 +1435,8 @@ export function TimelineContainer() {
prev.startFrame !== next.startFrame ||
prev.durationFrames !== next.durationFrames ||
prev.trackIndex !== next.trackIndex ||
prev.newTrackIndex !== next.newTrackIndex;
prev.newTrackIndex !== next.newTrackIndex ||
prev.rippleInsert !== next.rippleInsert;
if (changed) forceTick((n) => n + 1);
},
[toDoc, timeline, zoomScale, trackHeights, activeFrame],
Expand Down Expand Up @@ -1440,6 +1474,30 @@ export function TimelineContainer() {
// asset's standalone preview.
useEditorUiStore.getState().setPreviewMedia(null);
if (!item) return;
// Ripple-insert modifier (upstream `performDragOperation`: `let ripple =
// mods.contains(.command)`). ⌘/Ctrl held at drop → push existing clips
// right and insert at the drop frame instead of overwriting. Only applies
// to a plain full-asset drop onto an existing compatible track; moment
// (trimmed) drags and new-track drops fall through to the overwrite path.
const ripple = e.ctrlKey || e.metaKey;
if (
ripple &&
plan &&
plan.newTrackIndex === null &&
!momentRange &&
plan.trackIndex !== null
) {
const insertPlan = edit.buildMediaInsertPlan(
useProjectStore.getState().timeline,
item,
plan.startFrame,
plan.trackIndex,
);
if (insertPlan) {
void edit.insertClips(insertPlan.trackIndex, insertPlan.atFrame, insertPlan.entries);
return;
}
}
if (plan) {
const preferredTrackIndex = plan.newTrackIndex !== null ? null : plan.trackIndex;
const insertTrackAt = plan.newTrackIndex !== null ? plan.newTrackIndex : undefined;
Expand Down
29 changes: 26 additions & 3 deletions web/src/components/timeline/rulerCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
* minor ticks subdivide while each cell stays >= 12px.
*/

import { ACCENT, BG, BORDER, LAYOUT, TEXT } from "../../lib/theme";
import { ACCENT, BG, BORDER, LAYOUT, RANGE, TEXT } from "../../lib/theme";
import { chooseTicks } from "../../lib/ruler";
import { formatTimecode } from "../../lib/geometry";
import { formatTimecode, xForFrame } from "../../lib/geometry";
import { validRange, type TimelineRange } from "../../lib/timelineRange";

export interface RulerState {
fps: number;
pixelsPerFrame: number;
scrollLeft: number;
width: number; // visible width (CSS px)
dpr: number;
/** Active playhead frame for the timecode tint at its position (optional). */
/** Marked in/out range, painted as a translucent band + edge ticks on the
* ruler (upstream `drawTimelineRangeSelectionRulerFill` / `…Edges`). */
selectedRange?: TimelineRange | null;
}

export function paintRuler(ctx: CanvasRenderingContext2D, s: RulerState) {
Expand All @@ -28,6 +31,26 @@ export function paintRuler(ctx: CanvasRenderingContext2D, s: RulerState) {
ctx.fillStyle = BORDER.primary;
ctx.fillRect(0, LAYOUT.rulerHeight - 1, width, 1);

// Marked-range band on the ruler (upstream `drawTimelineRangeSelectionRulerFill`
// + `…Edges`): a soft fill across the range's x-span plus edge ticks. Drawn
// under the ticks/labels so timecode stays legible. `x` is view-space here
// (the ruler canvas is not scroll-translated), so subtract scrollLeft.
const range = validRange(s.selectedRange ?? null);
if (range) {
const minX = xForFrame(range.startFrame, pixelsPerFrame) - scrollLeft;
const maxX = xForFrame(range.endFrame, pixelsPerFrame) - scrollLeft;
ctx.fillStyle = RANGE.rulerFill;
ctx.fillRect(minX, 0, Math.max(0, maxX - minX), LAYOUT.rulerHeight);
ctx.strokeStyle = RANGE.edge;
ctx.lineWidth = 2;
for (const x of [minX, maxX]) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, LAYOUT.rulerHeight);
ctx.stroke();
}
}

const { majorInterval, minorSubdivisions } = chooseTicks(pixelsPerFrame, fps);

// First major frame at/after the left edge.
Expand Down
80 changes: 78 additions & 2 deletions web/src/components/timeline/timelineCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
* (SPEC §5.11), painted by the container.
*/

import { BG, BORDER, TEXT, LAYOUT, TRACK_SIZE, TRIM, GHOST } from "../../lib/theme";
import { clipRect, trackDisplayHeight, trackY } from "../../lib/geometry";
import { BG, BORDER, TEXT, LAYOUT, TRACK_SIZE, TRIM, GHOST, RANGE } from "../../lib/theme";
import { clipRect, trackDisplayHeight, trackY, xForFrame } from "../../lib/geometry";
import { linkOffsetForClip } from "../../lib/clip";
import { drawClip, roundRectPath, type ClipThumbnailStrip } from "./clipRenderer";
import { validRange, type TimelineRange } from "../../lib/timelineRange";
import type { GapSelection } from "../../lib/timelineGap";
import type { Timeline, ClipType } from "../../lib/types";

export interface PaintState {
Expand Down Expand Up @@ -45,6 +47,12 @@ export interface PaintState {
* resolved track + frame span (and a "new track" lane when the drop creates
* one). Absent when no media drag is over the timeline. */
mediaGhost?: MediaGhostPaint;
/** Marked in/out range (raw endpoints; gated through `validRange`). Painted as
* a track-area fill + edge lines (upstream `drawTimelineRangeSelection*`). */
selectedRange?: TimelineRange | null;
/** Selected empty gap between clips — dashed highlight on its track (upstream
* `drawGapSelection`). */
selectedGap?: GapSelection | null;
}

/** A media-panel drag projected over the timeline, for the drop-ghost preview. */
Expand All @@ -57,6 +65,10 @@ export interface MediaGhostPaint {
trackIndex: number | null;
/** Insert index of the new track to create, or null for an existing track. */
newTrackIndex: number | null;
/** ⌘/Ctrl held → the drop ripple-inserts (pushes existing clips right). Draws
* an insertion line at the drop frame (upstream `drawRippleInsertIndicator`)
* instead of the overwrite ghost's plain gray rect. */
rippleInsert?: boolean;
}

/** A live move/trim, projected for ghost rendering. */
Expand Down Expand Up @@ -112,6 +124,22 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) {
ctx.fillRect(scrollLeft, dy, s.viewWidth, 2);
}

// 2. Marked-range track fill (behind clips, upstream
// `drawTimelineRangeSelectionTrackFill`): a faint Text.primary band spanning
// every track's height across the range's frame span. Edges are drawn after
// the clips so they sit on top.
const range = validRange(s.selectedRange ?? null);
if (range && timeline.tracks.length > 0) {
const minX = xForFrame(range.startFrame, pixelsPerFrame);
const maxX = xForFrame(range.endFrame, pixelsPerFrame);
const top = trackY(timeline, 0, trackHeights);
const lastBottom =
trackY(timeline, timeline.tracks.length - 1, trackHeights) +
trackDisplayHeight(timeline.tracks[timeline.tracks.length - 1], trackHeights);
ctx.fillStyle = RANGE.trackFill;
ctx.fillRect(minX, top, Math.max(0, maxX - minX), Math.max(0, lastBottom - top));
}

// 3. Clips (skip those fully outside the visible window). A clip being dragged
// is drawn at its live (offset) position as a ghost so it follows the cursor.
const drag = s.drag;
Expand Down Expand Up @@ -263,6 +291,54 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) {
ctx.strokeStyle = GHOST.border;
ctx.lineWidth = 1;
ctx.stroke();
// Ripple-insert: a solid yellow insertion line at the drop frame (upstream
// `drawRippleInsertIndicator`) so a ⌘-drop reads as "push + insert here".
if (mg.rippleInsert && ghostX >= scrollLeft && ghostX <= visRight) {
ctx.strokeStyle = GHOST.insertLine;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(ghostX, ghostY - 2);
ctx.lineTo(ghostX, ghostY + ghostH + 2);
ctx.stroke();
}
}
}

// Selected-gap highlight (upstream `drawGapSelection`): a dashed white box on
// the gap's track, inset 2px top/bottom like a clip. Drawn over the clips.
const gap = s.selectedGap;
if (gap && gap.trackIndex < timeline.tracks.length && gap.endFrame > gap.startFrame) {
const gx = xForFrame(gap.startFrame, pixelsPerFrame);
const gw = xForFrame(gap.endFrame, pixelsPerFrame) - gx;
if (gx + gw >= scrollLeft && gx <= visRight) {
const gy = trackY(timeline, gap.trackIndex, trackHeights) + 2;
const gh = trackDisplayHeight(timeline.tracks[gap.trackIndex], trackHeights) - 4;
ctx.fillStyle = RANGE.gapFill;
ctx.fillRect(gx, gy, gw, gh);
ctx.strokeStyle = RANGE.gapStroke;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.strokeRect(gx + 0.5, gy + 0.5, gw - 1, gh - 1);
ctx.setLineDash([]);
}
}

// Marked-range edge lines on top (upstream `drawTimelineRangeSelectionEdges`):
// vertical Accent.timecode strokes at the range start + end, full track height.
if (range && timeline.tracks.length > 0) {
const top = trackY(timeline, 0, trackHeights);
const lastBottom =
trackY(timeline, timeline.tracks.length - 1, trackHeights) +
trackDisplayHeight(timeline.tracks[timeline.tracks.length - 1], trackHeights);
ctx.strokeStyle = RANGE.edge;
ctx.lineWidth = 2;
for (const f of [range.startFrame, range.endFrame]) {
const x = xForFrame(f, pixelsPerFrame);
if (x < scrollLeft || x > visRight) continue;
ctx.beginPath();
ctx.moveTo(x, top);
ctx.lineTo(x, lastBottom);
ctx.stroke();
}
}

Expand Down
Loading
Loading