From ab7669b37b45d9ac609929254b2c6e5cea9c7469 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 3 Jul 2026 00:29:19 +0800 Subject: [PATCH] feat(timeline): marked I/O range + gap selection + ripple insert + clip nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four timeline interactions, frontend-only (InsertClips / RippleDeleteRanges backends already existed unreached): - Marked range (upstream 1:1): I/O mark start/end at the playhead (modifierless only, EditorWindowController.swift:104-113); single-endpoint collapses to a point, normalized swaps inverted ends, valid iff end>start (half-open), Escape clears range + selection. Drawn as the upstream track fill (0.06) + ruler band (0.10) + timecode-accent edge lines (0.80). Pure timelineRange.ts, 12 tests. - Gap selection + ripple close: clicking empty track space between clips selects the gap ([prevEnd, nextStart), open tail not selectable — upstream hitTestGap TimelineInputController.swift:730-746); dashed white box drawing; mutually exclusive with clip selection both ways. Shift+Delete routes gap -> marked-range (anchor clip's track, upstream :88) -> selected clips, exactly upstream EditorWindowController.swift:77-83. timelineGap.ts, 7 tests. - Ripple-insert drop: Cmd/Ctrl held during a media drop routes to InsertClips instead of AddClips (upstream performDragOperation TimelineView.swift:996) with the yellow insert indicator during dragover (:213-215). timelineInsert.ts plan builder, 8 tests; minimal browser-fallback case so vite dev demos it. - Clip nudge (OpenTake extension, user-requested; NOT upstream — documented): , / . move selected clips ±1 frame (±5 with Shift), arrows stay playhead (upstream-correct). Link groups expanded before moveClips (backend move_clips does NOT auto-expand — verified; matches the drag path), group floor at 0. timelineNudge.ts, 7 tests. +8 canvas render-path tests (colors/coords). Gates: fmt/clippy clean; cargo test --workspace 1518; pnpm build clean; pnpm test 378 (+42). --- .../components/timeline/TimelineContainer.tsx | 70 ++++++- web/src/components/timeline/rulerCanvas.ts | 29 ++- web/src/components/timeline/timelineCanvas.ts | 80 +++++++- .../timeline/timelineOverlays.test.ts | 180 ++++++++++++++++++ web/src/hooks/useKeyboardShortcuts.ts | 48 ++++- web/src/lib/fallback.ts | 23 +++ web/src/lib/theme.ts | 17 ++ web/src/lib/timelineGap.test.ts | 66 +++++++ web/src/lib/timelineGap.ts | 62 ++++++ web/src/lib/timelineInsert.test.ts | 92 +++++++++ web/src/lib/timelineInsert.ts | 87 +++++++++ web/src/lib/timelineNudge.test.ts | 79 ++++++++ web/src/lib/timelineNudge.ts | 56 ++++++ web/src/lib/timelineRange.test.ts | 66 +++++++ web/src/lib/timelineRange.ts | 63 ++++++ web/src/store/editActions.ts | 110 +++++++++++ web/src/store/uiStore.ts | 53 +++++- 17 files changed, 1165 insertions(+), 16 deletions(-) create mode 100644 web/src/components/timeline/timelineOverlays.test.ts create mode 100644 web/src/lib/timelineGap.test.ts create mode 100644 web/src/lib/timelineGap.ts create mode 100644 web/src/lib/timelineInsert.test.ts create mode 100644 web/src/lib/timelineInsert.ts create mode 100644 web/src/lib/timelineNudge.test.ts create mode 100644 web/src/lib/timelineNudge.ts create mode 100644 web/src/lib/timelineRange.test.ts create mode 100644 web/src/lib/timelineRange.ts diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index 6e81ed2..95ef639 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -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"; @@ -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); @@ -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, @@ -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( @@ -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, @@ -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( @@ -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; @@ -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], @@ -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; diff --git a/web/src/components/timeline/rulerCanvas.ts b/web/src/components/timeline/rulerCanvas.ts index 14755e1..4712a99 100644 --- a/web/src/components/timeline/rulerCanvas.ts +++ b/web/src/components/timeline/rulerCanvas.ts @@ -4,9 +4,10 @@ * 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; @@ -14,7 +15,9 @@ export interface RulerState { 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) { @@ -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. diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index 54ecd59..7eaa8bd 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -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 { @@ -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. */ @@ -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. */ @@ -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; @@ -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(); } } diff --git a/web/src/components/timeline/timelineOverlays.test.ts b/web/src/components/timeline/timelineOverlays.test.ts new file mode 100644 index 0000000..4e0a143 --- /dev/null +++ b/web/src/components/timeline/timelineOverlays.test.ts @@ -0,0 +1,180 @@ +/** + * Render-path coverage for the marked-range / gap / ripple-insert overlays. + * Drives `paintTimeline` and `paintRuler` against a recording 2D-context stub + * and asserts the exact fillRect / stroke calls (color + geometry) that a + * screenshot would show — deterministic, no browser required. + */ +import { describe, expect, it } from "vitest"; +import type { Clip, Timeline, Track } from "../../lib/types"; +import { RANGE } from "../../lib/theme"; +import { paintTimeline, type PaintState, type MediaGhostPaint } from "./timelineCanvas"; +import { paintRuler, type RulerState } from "./rulerCanvas"; + +function clip(id: string, startFrame: number, durationFrames: number): Clip { + return { + id, + mediaRef: `${id}-m`, + mediaType: "video", + sourceClipType: "video", + startFrame, + durationFrames, + trimStartFrame: 0, + trimEndFrame: 0, + speed: 1, + volume: 1, + fadeInFrames: 0, + fadeOutFrames: 0, + fadeInInterpolation: "linear", + fadeOutInterpolation: "linear", + opacity: 1, + transform: { centerX: 0.5, centerY: 0.5, width: 1, height: 1, rotation: 0, flipHorizontal: false, flipVertical: false }, + crop: { left: 0, top: 0, right: 0, bottom: 0 }, + }; +} + +function timeline(): Timeline { + const v1: Track = { id: "v1", type: "video", muted: false, hidden: false, syncLocked: true, clips: [clip("a", 0, 100), clip("b", 200, 100)] }; + return { fps: 30, width: 1920, height: 1080, settingsConfigured: true, tracks: [v1] }; +} + +/** A minimal recording CanvasRenderingContext2D. Captures fillStyle/strokeStyle + * at each fillRect/stroke so the test can assert what colors were painted. */ +function recordingCtx() { + const calls: { op: string; style: string; args: number[] }[] = []; + let fillStyle = ""; + let strokeStyle = ""; + const stub = { + set fillStyle(v: string) { fillStyle = v; }, + get fillStyle() { return fillStyle; }, + set strokeStyle(v: string) { strokeStyle = v; }, + get strokeStyle() { return strokeStyle; }, + lineWidth: 1, + font: "", + textAlign: "left", + textBaseline: "alphabetic", + setTransform() {}, + clearRect() {}, + fillRect(...a: number[]) { calls.push({ op: "fillRect", style: fillStyle, args: a }); }, + strokeRect(...a: number[]) { calls.push({ op: "strokeRect", style: strokeStyle, args: a }); }, + fillText() {}, + beginPath() {}, + moveTo() {}, + lineTo() {}, + stroke() { calls.push({ op: "stroke", style: strokeStyle, args: [] }); }, + fill() { calls.push({ op: "fill", style: fillStyle, args: [] }); }, + setLineDash() {}, + save() {}, + restore() {}, + translate() {}, + scale() {}, + rect() {}, + arc() {}, + arcTo() {}, + ellipse() {}, + bezierCurveTo() {}, + closePath() {}, + quadraticCurveTo() {}, + clip() {}, + createLinearGradient() { return { addColorStop() {} }; }, + measureText() { return { width: 10 }; }, + drawImage() {}, + roundRect() {}, + }; + return { ctx: stub as unknown as CanvasRenderingContext2D, calls }; +} + +function baseState(over: Partial): PaintState { + return { + timeline: timeline(), + pixelsPerFrame: 4, + trackHeights: {}, + selectedClipIds: new Set(), + dpr: 1, + width: 2000, + height: 400, + firstAudioIndex: -1, + scrollLeft: 0, + scrollTop: 0, + viewWidth: 2000, + viewHeight: 400, + waveforms: new Map(), + thumbnails: new Map(), + missingMediaRefs: new Set(), + emptyLabel: "", + ...over, + }; +} + +describe("marked-range overlay (content canvas)", () => { + it("paints the range track fill + two edge strokes at the range x-span", () => { + const { ctx, calls } = recordingCtx(); + paintTimeline(ctx, baseState({ selectedRange: { startFrame: 40, endFrame: 90 } })); + // Track fill uses RANGE.trackFill starting at x = 40 * 4 = 160. + const fill = calls.find((c) => c.op === "fillRect" && c.style === RANGE.trackFill); + expect(fill).toBeDefined(); + expect(fill!.args[0]).toBe(160); + expect(fill!.args[2]).toBe((90 - 40) * 4); // width = 200 + // Two edge strokes in the accent-timecode color. + const edges = calls.filter((c) => c.op === "stroke" && c.style === RANGE.edge); + expect(edges.length).toBe(2); + }); + + it("normalizes an inverted range before painting", () => { + const { ctx, calls } = recordingCtx(); + paintTimeline(ctx, baseState({ selectedRange: { startFrame: 90, endFrame: 40 } })); + const fill = calls.find((c) => c.op === "fillRect" && c.style === RANGE.trackFill); + expect(fill!.args[0]).toBe(160); // min edge, not 360 + }); + + it("paints nothing for a collapsed range", () => { + const { ctx, calls } = recordingCtx(); + paintTimeline(ctx, baseState({ selectedRange: { startFrame: 50, endFrame: 50 } })); + expect(calls.some((c) => c.style === RANGE.trackFill)).toBe(false); + expect(calls.some((c) => c.style === RANGE.edge)).toBe(false); + }); +}); + +describe("gap overlay (content canvas)", () => { + it("paints a dashed box on the gap's track", () => { + const { ctx, calls } = recordingCtx(); + paintTimeline(ctx, baseState({ selectedGap: { trackIndex: 0, startFrame: 100, endFrame: 200 } })); + expect(calls.some((c) => c.op === "fillRect" && c.style === RANGE.gapFill)).toBe(true); + expect(calls.some((c) => c.op === "strokeRect" && c.style === RANGE.gapStroke)).toBe(true); + }); +}); + +describe("ripple-insert indicator (content canvas)", () => { + it("draws a yellow insertion line when the media ghost is a ripple insert", () => { + const ghost: MediaGhostPaint = { startFrame: 120, durationFrames: 60, trackIndex: 0, newTrackIndex: null, rippleInsert: true }; + const { ctx, calls } = recordingCtx(); + paintTimeline(ctx, baseState({ mediaGhost: ghost })); + // The insert line strokes in GHOST.insertLine (yellow) — distinct from the + // gray overwrite-ghost border. + const yellowStroke = calls.filter((c) => c.op === "stroke" && c.style === "rgb(255,204,0)"); + expect(yellowStroke.length).toBeGreaterThan(0); + }); + + it("does NOT draw the insertion line for a plain overwrite ghost", () => { + const ghost: MediaGhostPaint = { startFrame: 120, durationFrames: 60, trackIndex: 0, newTrackIndex: null, rippleInsert: false }; + const { ctx, calls } = recordingCtx(); + paintTimeline(ctx, baseState({ mediaGhost: ghost })); + expect(calls.some((c) => c.op === "stroke" && c.style === "rgb(255,204,0)")).toBe(false); + }); +}); + +describe("marked-range overlay (ruler canvas)", () => { + function rulerState(over: Partial): RulerState { + return { fps: 30, pixelsPerFrame: 4, scrollLeft: 0, width: 2000, dpr: 1, ...over }; + } + it("paints the ruler band fill + edge strokes", () => { + const { ctx, calls } = recordingCtx(); + paintRuler(ctx, rulerState({ selectedRange: { startFrame: 40, endFrame: 90 } })); + expect(calls.some((c) => c.op === "fillRect" && c.style === RANGE.rulerFill)).toBe(true); + expect(calls.filter((c) => c.op === "stroke" && c.style === RANGE.edge).length).toBe(2); + }); + it("paints no band without a range", () => { + const { ctx, calls } = recordingCtx(); + paintRuler(ctx, rulerState({ selectedRange: null })); + expect(calls.some((c) => c.style === RANGE.rulerFill)).toBe(false); + }); +}); diff --git a/web/src/hooks/useKeyboardShortcuts.ts b/web/src/hooks/useKeyboardShortcuts.ts index b975854..6414873 100644 --- a/web/src/hooks/useKeyboardShortcuts.ts +++ b/web/src/hooks/useKeyboardShortcuts.ts @@ -177,9 +177,23 @@ export function useKeyboardShortcuts() { case "Backspace": case "Delete": e.preventDefault(); - // ⇧⌫ ripple-deletes (closes the gap); plain ⌫ lifts out (leaves a gap). - if (e.shiftKey) edit.rippleDeleteSelectedClips(); - else edit.deleteSelectedClips(); + if (e.shiftKey) { + // ⇧⌫ ripple-deletes (closes the gap). Route like upstream's + // EditorWindowController: a selected gap closes first; else a marked + // range on the selected clip's track; else the selected clips. + if (ui.selectedGap) { + void edit.rippleDeleteSelectedGap(); + } else { + void (async () => { + if (!(await edit.rippleDeleteMarkedRange())) { + await edit.rippleDeleteSelectedClips(); + } + })(); + } + } else { + // Plain ⌫ lifts out (leaves a gap). + void edit.deleteSelectedClips(); + } return; case "KeyQ": // 剪映 Q:删除播放头左侧(修剪入点到播放头)。 @@ -191,6 +205,32 @@ export function useKeyboardShortcuts() { e.preventDefault(); edit.trimEndToPlayhead(); return; + case "KeyI": + // I: mark the range IN point at the playhead (upstream keyCode 34 + // markTimelineRangeStart). No modifiers (matches upstream's + // `rangeMarkShortcut` = no Cmd/Alt/Ctrl). + e.preventDefault(); + ui.markRangeStart(Math.round(ui.activeFrame)); + return; + case "KeyO": + // O: mark the range OUT point at the playhead (upstream keyCode 31 + // markTimelineRangeEnd). + e.preventDefault(); + ui.markRangeEnd(Math.round(ui.activeFrame)); + return; + case "Comma": + // OpenTake extension (NOT upstream): nudge selected clips left by 1 + // frame (5 with Shift). , / . is the NLE nudge convention; arrows are + // the playhead (upstream-correct), so nudge gets its own keys. + e.preventDefault(); + void edit.nudgeSelectedClips(e.shiftKey ? -5 : -1); + return; + case "Period": + // OpenTake extension (NOT upstream): nudge selected clips right by 1 + // frame (5 with Shift). + e.preventDefault(); + void edit.nudgeSelectedClips(e.shiftKey ? 5 : 1); + return; case "KeyC": case "KeyB": // C (existing) and B (剪映 切割模式) both enter the razor/blade tool. @@ -218,7 +258,9 @@ export function useKeyboardShortcuts() { case "Escape": if (ui.maximizedPanel) ui.setMaximizedPanel(null); else { + // Upstream Escape clears clip selection AND the marked range. ui.clearSelection(); + ui.clearTimelineRange(); ui.setToolMode("pointer"); } return; diff --git a/web/src/lib/fallback.ts b/web/src/lib/fallback.ts index 8b68a33..3380bd8 100644 --- a/web/src/lib/fallback.ts +++ b/web/src/lib/fallback.ts @@ -335,6 +335,29 @@ export function createFallbackStore() { } return result(affected.length > 0, affected.length === 1 ? "Add Clip" : "Add Clips", affected); } + case "insertClips": { + // Minimal ripple insert for the browser shell: push clips at/after + // atFrame right by the total inserted duration on the target track, + // then place the new clips. (Sync-lock / linked-audio ripple is a Rust + // concern; the shell only needs the visible push on the target track.) + const track = timeline.tracks[cmd.trackIndex]; + if (!track) return result(false, "Insert Clips", []); + const totalPush = cmd.entries.reduce((sum, en) => sum + Math.max(1, en.durationFrames), 0); + for (const c of track.clips) { + if (c.startFrame >= cmd.atFrame) c.startFrame += totalPush; + } + const affected: string[] = []; + let cursor = cmd.atFrame; + for (const entry of cmd.entries) { + const id = nextId(); + const clip = newClipFromEntry(id, { ...entry, startFrame: cursor }); + track.clips.push(clip); + affected.push(id); + cursor += Math.max(1, entry.durationFrames); + } + track.clips.sort((a, b) => a.startFrame - b.startFrame); + return result(affected.length > 0, affected.length === 1 ? "Insert Clip" : "Insert Clips", affected); + } case "removeClips": { let changed = false; for (const track of timeline.tracks) { diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts index b3593ba..e7a1569 100644 --- a/web/src/lib/theme.ts +++ b/web/src/lib/theme.ts @@ -149,6 +149,23 @@ export const GHOST = { insertLine: "rgb(255,204,0)", } as const; +/** Marked in/out range + gap selection overlays (upstream + * `TimelineView.drawTimelineRangeSelection*` / `drawGapSelection`). Opacities + * are the exact upstream `AppTheme.Opacity` literals (hint 0.06, soft 0.10, + * prominent 0.80); the gap uses upstream's inline white 0.12 / 0.9. */ +export const RANGE = { + /** Track-area fill (Text.primary @ hint). */ + trackFill: "rgba(255,255,255,0.06)", + /** Ruler-band fill (Text.primary @ soft). */ + rulerFill: "rgba(255,255,255,0.10)", + /** Start/end edge lines (Accent.timecode @ prominent). */ + edge: "rgba(242,153,51,0.80)", + /** Selected-gap fill. */ + gapFill: "rgba(255,255,255,0.12)", + /** Selected-gap dashed stroke. */ + gapStroke: "rgba(255,255,255,0.9)", +} as const; + /** §5.4 Clip rendering insets (ClipRenderer.swift). */ export const CLIP = { stripWidth: 3, // left color strip diff --git a/web/src/lib/timelineGap.test.ts b/web/src/lib/timelineGap.test.ts new file mode 100644 index 0000000..aa66e84 --- /dev/null +++ b/web/src/lib/timelineGap.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { Clip, Timeline, Track } from "./types"; +import { gapAtFrame } from "./timelineGap"; + +function clip(id: string, startFrame: number, durationFrames: number): Clip { + return { + id, + mediaRef: `${id}-m`, + mediaType: "video", + sourceClipType: "video", + startFrame, + durationFrames, + trimStartFrame: 0, + trimEndFrame: 0, + speed: 1, + volume: 1, + fadeInFrames: 0, + fadeOutFrames: 0, + fadeInInterpolation: "linear", + fadeOutInterpolation: "linear", + opacity: 1, + transform: { centerX: 0.5, centerY: 0.5, width: 1, height: 1, rotation: 0, flipHorizontal: false, flipVertical: false }, + crop: { left: 0, top: 0, right: 0, bottom: 0 }, + }; +} + +function timeline(clips: Clip[]): Timeline { + const track: Track = { id: "t1", type: "video", muted: false, hidden: false, syncLocked: true, clips }; + return { fps: 30, width: 1920, height: 1080, settingsConfigured: true, tracks: [track] }; +} + +describe("gapAtFrame", () => { + // clips at [0,100) and [200,300): gap is [100,200). + const tl = timeline([clip("a", 0, 100), clip("b", 200, 100)]); + + it("selects the gap between two clips", () => { + expect(gapAtFrame(tl, 0, 150)).toEqual({ trackIndex: 0, startFrame: 100, endFrame: 200 }); + }); + + it("uses the clip's end as the left edge at the gap boundary", () => { + // frame 100 is the first empty frame (clip a ends exclusive at 100). + expect(gapAtFrame(tl, 0, 100)).toEqual({ trackIndex: 0, startFrame: 100, endFrame: 200 }); + }); + + it("returns null when the frame is inside a clip", () => { + expect(gapAtFrame(tl, 0, 50)).toBeNull(); + expect(gapAtFrame(tl, 0, 250)).toBeNull(); + }); + + it("returns null past the last clip (no right-bounding clip)", () => { + expect(gapAtFrame(tl, 0, 400)).toBeNull(); + }); + + it("selects a leading gap [0, firstStart) before the first clip", () => { + const lead = timeline([clip("b", 200, 100)]); + expect(gapAtFrame(lead, 0, 50)).toEqual({ trackIndex: 0, startFrame: 0, endFrame: 200 }); + }); + + it("returns null for an out-of-range track", () => { + expect(gapAtFrame(tl, 5, 150)).toBeNull(); + }); + + it("returns null for an empty track (no right bound)", () => { + expect(gapAtFrame(timeline([]), 0, 10)).toBeNull(); + }); +}); diff --git a/web/src/lib/timelineGap.ts b/web/src/lib/timelineGap.ts new file mode 100644 index 0000000..016a53a --- /dev/null +++ b/web/src/lib/timelineGap.ts @@ -0,0 +1,62 @@ +/** + * Empty-region (gap) selection between clips on one track — pure helpers, 1:1 + * port of upstream `TimelineInputController.hitTestGap`. A gap is the empty span + * `[previousClipEnd, nextClipStart)` on a track, bounded on the RIGHT by a clip + * (an open-ended tail past the last clip is not a selectable gap upstream). See + * SPEC §5 / upstream `GapSelection`. + */ + +import type { Timeline } from "./types"; + +export interface GapSelection { + trackIndex: number; + startFrame: number; + endFrame: number; +} + +interface GapClip { + startFrame: number; + durationFrames: number; +} + +function endOf(clip: GapClip): number { + return clip.startFrame + clip.durationFrames; +} + +/** + * The gap on `trackIndex` containing project `frame`, or null. Returns null when + * `frame` lands inside any clip, or when there is no clip to the right (upstream + * requires a `nextStart`, so the open tail past the last clip is not a gap). The + * gap's left edge is the max end of clips ending at/before `frame` (0 if none). + * + * Pure mirror of upstream `hitTestGap` MINUS the y-band check (the caller has + * already resolved the track from the pointer's y, exactly as upstream does). + */ +export function gapAtFrame( + timeline: Timeline, + trackIndex: number, + frame: number, +): GapSelection | null { + const track = timeline.tracks[trackIndex]; + if (!track) return null; + const clips = track.clips as GapClip[]; + + // Inside a clip → not a gap. + if (clips.some((c) => frame >= c.startFrame && frame < endOf(c))) return null; + + // Must be bounded on the right by a clip. + let nextStart = Number.POSITIVE_INFINITY; + for (const c of clips) { + if (c.startFrame > frame && c.startFrame < nextStart) nextStart = c.startFrame; + } + if (!Number.isFinite(nextStart)) return null; + + // Left edge: latest end at/before `frame`, else 0. + let prevEnd = 0; + for (const c of clips) { + const e = endOf(c); + if (e <= frame && e > prevEnd) prevEnd = e; + } + + return { trackIndex, startFrame: prevEnd, endFrame: nextStart }; +} diff --git a/web/src/lib/timelineInsert.test.ts b/web/src/lib/timelineInsert.test.ts new file mode 100644 index 0000000..00f0c62 --- /dev/null +++ b/web/src/lib/timelineInsert.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { MediaItem, Timeline, Track, Transform } from "./types"; +import { buildInsertPlan, resolveInsertTrack } from "./timelineInsert"; + +const identity: Transform = { + centerX: 0.5, + centerY: 0.5, + width: 1, + height: 1, + rotation: 0, + flipHorizontal: false, + flipVertical: false, +}; +const fit = () => identity; + +function track(id: string, type: Track["type"]): Track { + return { id, type, muted: false, hidden: false, syncLocked: true, clips: [] }; +} + +function timeline(tracks: Track[]): Timeline { + return { fps: 30, width: 1920, height: 1080, settingsConfigured: true, tracks }; +} + +function media(overrides: Partial = {}): MediaItem { + return { + id: "m1", + name: "clip.mp4", + type: "video", + duration: 4, + width: 1920, + height: 1080, + hasAudio: true, + ...overrides, + } as MediaItem; +} + +describe("resolveInsertTrack", () => { + const tl = timeline([track("v1", "video"), track("a1", "audio")]); + + it("prefers the given track when compatible", () => { + expect(resolveInsertTrack(tl, "video", 0)).toBe(0); + }); + + it("falls back to the first compatible track when the preferred is incompatible", () => { + // audio item preferring the video track → first audio track (index 1). + expect(resolveInsertTrack(tl, "audio", 0)).toBe(1); + }); + + it("returns null when no compatible track exists", () => { + expect(resolveInsertTrack(timeline([track("v1", "video")]), "audio", null)).toBeNull(); + }); + + it("treats image/text/lottie as visual (video zone)", () => { + expect(resolveInsertTrack(tl, "image", null)).toBe(0); + }); +}); + +describe("buildInsertPlan", () => { + const tl = timeline([track("v1", "video"), track("a1", "audio")]); + + it("builds a plan placing at the (clamped) drop frame on the resolved track", () => { + const plan = buildInsertPlan(tl, media(), 120, 0, fit, 5); + expect(plan).not.toBeNull(); + expect(plan!.trackIndex).toBe(0); + expect(plan!.atFrame).toBe(120); + expect(plan!.entries).toHaveLength(1); + const [entry] = plan!.entries; + expect(entry.startFrame).toBe(120); + // 4s * 30fps = 120 frames. + expect(entry.durationFrames).toBe(120); + // A video with audio requests a linked audio partner (upstream parity). + expect(entry.addLinkedAudio).toBe(true); + }); + + it("uses the still-image default duration when the item has no length", () => { + const plan = buildInsertPlan(tl, media({ type: "image", duration: 0, hasAudio: false }), 0, 0, fit, 5); + // 5s default * 30fps. + expect(plan!.entries[0].durationFrames).toBe(150); + expect(plan!.entries[0].addLinkedAudio).toBe(false); + }); + + it("clamps a negative drop frame to 0", () => { + const plan = buildInsertPlan(tl, media(), -30, 0, fit, 5); + expect(plan!.atFrame).toBe(0); + expect(plan!.entries[0].startFrame).toBe(0); + }); + + it("returns null when no compatible track exists", () => { + const audioOnly = timeline([track("v1", "video")]); + expect(buildInsertPlan(audioOnly, media({ type: "audio" }), 0, null, fit, 5)).toBeNull(); + }); +}); diff --git a/web/src/lib/timelineInsert.ts b/web/src/lib/timelineInsert.ts new file mode 100644 index 0000000..7758f02 --- /dev/null +++ b/web/src/lib/timelineInsert.ts @@ -0,0 +1,87 @@ +/** + * Ripple-insert drop planning — pure helper. Builds the `InsertClips` payload + * for a media item dropped onto the timeline while the ripple modifier is held + * (upstream `TimelineView.performDragOperation`: `let ripple = mods.contains(.command)` + * routes to `rippleInsertClips` instead of `addClips`). The backend `ripple_insert` + * opens a gap at `atFrame` on the target track + every sync-locked track (and the + * linked-audio track when a video clip carries audio), so this only needs to + * resolve the target track + build one entry. See SPEC §5 / upstream + * `EditorViewModel+Ripple.swift rippleInsertClips`. + */ + +import type { ClipEntryReq, ClipType, MediaItem, Timeline, Transform } from "./types"; + +export interface InsertPlan { + trackIndex: number; + atFrame: number; + entries: ClipEntryReq[]; +} + +/** Frame length a media item occupies (duplicated tiny rule from editActions to + * keep this module import-free of the store; stills get a default length). */ +function durationFramesFor(item: MediaItem, fps: number, defaultImageSeconds: number): number { + const seconds = item.duration > 0 ? item.duration : defaultImageSeconds; + return Math.max(1, Math.round(seconds * fps)); +} + +function isVisual(type: ClipType): boolean { + return type === "video" || type === "image" || type === "text" || type === "lottie"; +} + +/** First existing track whose kind matches the item, preferring `preferred` + * when compatible; null when the timeline has no compatible track. Ripple + * insert never creates the *target* track here (unlike overwrite drops) — a + * drop with no compatible track simply can't ripple-insert, so the caller + * falls back to a plain add. */ +export function resolveInsertTrack( + timeline: Timeline, + type: ClipType, + preferred: number | null, +): number | null { + const wantAudio = type === "audio"; + const compatible = (i: number): boolean => { + const t = timeline.tracks[i]?.type; + if (!t) return false; + return wantAudio ? t === "audio" : !(t === "audio") && isVisual(t); + }; + if (preferred !== null && compatible(preferred)) return preferred; + for (let i = 0; i < timeline.tracks.length; i++) if (compatible(i)) return i; + return null; +} + +/** + * Build the ripple-insert plan for `item` dropped at `atFrame` over + * `preferredTrackIndex`, or null when no compatible target track exists. The + * entry mirrors the overwrite-drop entry (`entryForMediaAt`) except placement is + * an insert: the backend pushes existing clips right by the duration. + */ +export function buildInsertPlan( + timeline: Timeline, + item: MediaItem, + atFrame: number, + preferredTrackIndex: number | null, + fitTransform: ( + mw: number | null | undefined, + mh: number | null | undefined, + tw: number, + th: number, + ) => Transform, + defaultImageSeconds: number, +): InsertPlan | null { + const trackIndex = resolveInsertTrack(timeline, item.type, preferredTrackIndex); + if (trackIndex === null) return null; + const at = Math.max(0, atFrame); + const durationFrames = durationFramesFor(item, timeline.fps, defaultImageSeconds); + const entry: ClipEntryReq = { + mediaRef: item.id, + mediaType: item.type, + sourceClipType: item.type, + trackIndex, + startFrame: at, + durationFrames, + hasAudio: item.hasAudio, + addLinkedAudio: item.type === "video" && item.hasAudio, + transform: fitTransform(item.width, item.height, timeline.width, timeline.height), + }; + return { trackIndex, atFrame: at, entries: [entry] }; +} diff --git a/web/src/lib/timelineNudge.test.ts b/web/src/lib/timelineNudge.test.ts new file mode 100644 index 0000000..0733a2c --- /dev/null +++ b/web/src/lib/timelineNudge.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { Clip, Timeline, Track } from "./types"; +import { planNudge } from "./timelineNudge"; + +function clip(id: string, startFrame: number, mediaType: Clip["mediaType"] = "video"): Clip { + return { + id, + mediaRef: `${id}-m`, + mediaType, + sourceClipType: mediaType, + startFrame, + durationFrames: 50, + trimStartFrame: 0, + trimEndFrame: 0, + speed: 1, + volume: 1, + fadeInFrames: 0, + fadeOutFrames: 0, + fadeInInterpolation: "linear", + fadeOutInterpolation: "linear", + opacity: 1, + transform: { centerX: 0.5, centerY: 0.5, width: 1, height: 1, rotation: 0, flipHorizontal: false, flipVertical: false }, + crop: { left: 0, top: 0, right: 0, bottom: 0 }, + }; +} + +function timeline(tracks: Track[]): Timeline { + return { fps: 30, width: 1920, height: 1080, settingsConfigured: true, tracks }; +} + +function videoTrack(id: string, clips: Clip[]): Track { + return { id, type: "video", muted: false, hidden: false, syncLocked: true, clips }; +} + +describe("planNudge", () => { + const tl = timeline([videoTrack("v1", [clip("a", 100), clip("b", 300)])]); + + it("returns [] when nothing is selected", () => { + expect(planNudge(tl, new Set(), 1)).toEqual([]); + }); + + it("returns [] for a zero delta", () => { + expect(planNudge(tl, new Set(["a"]), 0)).toEqual([]); + }); + + it("nudges one clip forward preserving its track", () => { + expect(planNudge(tl, new Set(["a"]), 5)).toEqual([{ clipId: "a", toTrack: 0, toFrame: 105 }]); + }); + + it("nudges one clip backward", () => { + expect(planNudge(tl, new Set(["b"]), -1)).toEqual([{ clipId: "b", toTrack: 0, toFrame: 299 }]); + }); + + it("floors the whole group at frame 0 (never negative)", () => { + const t = timeline([videoTrack("v1", [clip("a", 2), clip("b", 20)])]); + // delta -5 would push 'a' to -3; group floors so 'a' lands at 0 and 'b' + // shifts by the same clamped -2. + expect(planNudge(t, new Set(["a", "b"]), -5)).toEqual([ + { clipId: "a", toTrack: 0, toFrame: 0 }, + { clipId: "b", toTrack: 0, toFrame: 18 }, + ]); + }); + + it("returns [] when a group already at 0 is nudged backward", () => { + const t = timeline([videoTrack("v1", [clip("a", 0)])]); + expect(planNudge(t, new Set(["a"]), -3)).toEqual([]); + }); + + it("moves clips across tracks together, each preserving its own track", () => { + const t = timeline([ + videoTrack("v1", [clip("v", 100)]), + { id: "a1", type: "audio", muted: false, hidden: false, syncLocked: true, clips: [clip("a", 100, "audio")] }, + ]); + expect(planNudge(t, new Set(["v", "a"]), 1)).toEqual([ + { clipId: "v", toTrack: 0, toFrame: 101 }, + { clipId: "a", toTrack: 1, toFrame: 101 }, + ]); + }); +}); diff --git a/web/src/lib/timelineNudge.ts b/web/src/lib/timelineNudge.ts new file mode 100644 index 0000000..e0fadfa --- /dev/null +++ b/web/src/lib/timelineNudge.ts @@ -0,0 +1,56 @@ +/** + * Keyboard clip nudge — pure move planner. OpenTake EXTENSION (no upstream + * grounding): shift selected clips by a whole-frame delta along the timeline, + * preserving each clip's track. The group floors as one unit so the earliest + * clip lands at frame 0 and the selection keeps its relative spacing (same + * `max(delta, -minStart)` rule the drag-move commit uses), rather than clamping + * each clip independently. Returns the `ClipMove`-shaped list `moveClips` wants; + * empty when nothing moves (no ids, or the delta floors to zero). + */ + +import type { ClipMoveReq, Timeline } from "./types"; + +interface NudgeClip { + id: string; + trackIndex: number; + startFrame: number; +} + +/** Resolve the selected ids to their current {id, trackIndex, startFrame}. */ +function selectedClips(timeline: Timeline, ids: Set): NudgeClip[] { + const out: NudgeClip[] = []; + for (let ti = 0; ti < timeline.tracks.length; ti++) { + for (const clip of timeline.tracks[ti].clips) { + if (ids.has(clip.id)) out.push({ id: clip.id, trackIndex: ti, startFrame: clip.startFrame }); + } + } + return out; +} + +/** + * Build the moves to nudge `selectedIds` by `deltaFrames` (may be negative). + * `selectedIds` should already include linked partners (the caller expands the + * link group so partners travel together). No-ops to `[]` when the set is empty + * or the floored delta is zero (e.g. the group is already at frame 0 and delta + * is negative). Track is preserved (`toTrack === trackIndex`). + */ +export function planNudge( + timeline: Timeline, + selectedIds: Set, + deltaFrames: number, +): ClipMoveReq[] { + if (selectedIds.size === 0 || deltaFrames === 0) return []; + const clips = selectedClips(timeline, selectedIds); + if (clips.length === 0) return []; + + const minStart = Math.min(...clips.map((c) => c.startFrame)); + // Floor the whole group at frame 0 (never push the earliest clip negative). + const applied = Math.max(deltaFrames, -minStart); + if (applied === 0) return []; + + return clips.map((c) => ({ + clipId: c.id, + toTrack: c.trackIndex, + toFrame: c.startFrame + applied, + })); +} diff --git a/web/src/lib/timelineRange.test.ts b/web/src/lib/timelineRange.test.ts new file mode 100644 index 0000000..793b8d3 --- /dev/null +++ b/web/src/lib/timelineRange.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + isValidRange, + normalizeRange, + rangeContains, + validRange, + withRangeEnd, + withRangeStart, +} from "./timelineRange"; + +describe("normalizeRange", () => { + it("keeps an already-ordered range", () => { + expect(normalizeRange({ startFrame: 10, endFrame: 40 })).toEqual({ startFrame: 10, endFrame: 40 }); + }); + it("swaps an inverted range", () => { + expect(normalizeRange({ startFrame: 40, endFrame: 10 })).toEqual({ startFrame: 10, endFrame: 40 }); + }); +}); + +describe("isValidRange", () => { + it("is false for a collapsed (single-endpoint) range", () => { + expect(isValidRange({ startFrame: 20, endFrame: 20 })).toBe(false); + }); + it("is true for a non-empty span, even inverted", () => { + expect(isValidRange({ startFrame: 40, endFrame: 10 })).toBe(true); + }); +}); + +describe("validRange", () => { + it("returns null for null", () => { + expect(validRange(null)).toBeNull(); + }); + it("returns null for a collapsed range", () => { + expect(validRange({ startFrame: 5, endFrame: 5 })).toBeNull(); + }); + it("returns the normalized range for a valid inverted range", () => { + expect(validRange({ startFrame: 40, endFrame: 10 })).toEqual({ startFrame: 10, endFrame: 40 }); + }); +}); + +describe("rangeContains", () => { + it("includes the start, excludes the end (half-open)", () => { + const r = { startFrame: 10, endFrame: 40 }; + expect(rangeContains(r, 10)).toBe(true); + expect(rangeContains(r, 39)).toBe(true); + expect(rangeContains(r, 40)).toBe(false); + expect(rangeContains(r, 9)).toBe(false); + }); +}); + +describe("withRangeStart / withRangeEnd", () => { + it("marks the start, keeping the existing end", () => { + expect(withRangeStart({ startFrame: 5, endFrame: 30 }, 12)).toEqual({ startFrame: 12, endFrame: 30 }); + }); + it("collapses to a point when there is no existing range", () => { + expect(withRangeStart(null, 12)).toEqual({ startFrame: 12, endFrame: 12 }); + expect(withRangeEnd(null, 12)).toEqual({ startFrame: 12, endFrame: 12 }); + }); + it("marks the end, keeping the existing start", () => { + expect(withRangeEnd({ startFrame: 5, endFrame: 30 }, 22)).toEqual({ startFrame: 5, endFrame: 22 }); + }); + it("clamps a negative frame to 0", () => { + expect(withRangeStart(null, -8)).toEqual({ startFrame: 0, endFrame: 0 }); + expect(withRangeEnd({ startFrame: 4, endFrame: 9 }, -3)).toEqual({ startFrame: 4, endFrame: 0 }); + }); +}); diff --git a/web/src/lib/timelineRange.ts b/web/src/lib/timelineRange.ts new file mode 100644 index 0000000..c070c50 --- /dev/null +++ b/web/src/lib/timelineRange.ts @@ -0,0 +1,63 @@ +/** + * Marked in/out timeline range — pure helpers, 1:1 port of upstream + * `Timeline/TimelineRangeSelection.swift`. The range is a project-frame span + * `[startFrame, endFrame)`; `startFrame` may exceed `endFrame` while the user is + * still marking (I set before O, or an inverted drag), so `normalize` swaps them + * and `isValid` requires a non-empty span. See SPEC §5 / upstream + * `validSelectedTimelineRange`. + */ + +export interface TimelineRange { + startFrame: number; + endFrame: number; +} + +/** Swap endpoints so `startFrame <= endFrame` (upstream `normalized`). */ +export function normalizeRange(range: TimelineRange): TimelineRange { + return range.startFrame <= range.endFrame + ? range + : { startFrame: range.endFrame, endFrame: range.startFrame }; +} + +/** A range is valid only when, once normalized, it spans at least one frame + * (upstream `isValid`: `endFrame > startFrame`). A single-endpoint range + * (start == end) is not yet valid. */ +export function isValidRange(range: TimelineRange): boolean { + const n = normalizeRange(range); + return n.endFrame > n.startFrame; +} + +/** The normalized range if valid, else null — the gate every consumer uses + * (upstream `validSelectedTimelineRange`). */ +export function validRange(range: TimelineRange | null): TimelineRange | null { + if (!range) return null; + const n = normalizeRange(range); + return isValidRange(n) ? n : null; +} + +/** Whether `frame` falls in `[startFrame, endFrame)` of the normalized range + * (upstream `contains(frame:)`). */ +export function rangeContains(range: TimelineRange, frame: number): boolean { + const n = normalizeRange(range); + return frame >= n.startFrame && frame < n.endFrame; +} + +/** Set the range start at `frame` (clamped `>= 0`), keeping the existing end or + * collapsing to a point when there is none (upstream `markTimelineRangeStart`). */ +export function withRangeStart( + existing: TimelineRange | null, + frame: number, +): TimelineRange { + const start = Math.max(0, frame); + return { startFrame: start, endFrame: existing?.endFrame ?? start }; +} + +/** Set the range end at `frame` (clamped `>= 0`), keeping the existing start or + * collapsing to a point when there is none (upstream `markTimelineRangeEnd`). */ +export function withRangeEnd( + existing: TimelineRange | null, + frame: number, +): TimelineRange { + const end = Math.max(0, frame); + return { startFrame: existing?.startFrame ?? end, endFrame: end }; +} diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 48fffe8..ea90318 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -11,6 +11,10 @@ import { useEditorUiStore } from "./uiStore"; import { useProjectStore } from "./projectStore"; import { fitTransformForMedia, trimToPlayheadEdits } from "../lib/clip"; import type { TrackDropTarget } from "../lib/geometry"; +import { validRange } from "../lib/timelineRange"; +import { planNudge } from "../lib/timelineNudge"; +import { buildInsertPlan, type InsertPlan } from "../lib/timelineInsert"; +import { expandLinkGroup } from "../components/timeline/hitTest"; import { useClipboardStore } from "./clipboardStore"; import type { CaptionEntryReq, @@ -53,6 +57,35 @@ export async function addClips(entries: ClipEntryReq[]) { return applyAndRefresh({ type: "addClips", entries }); } +/** Ripple-insert clips at `atFrame` on `trackIndex`: place the entries and push + * everything past the insert point right by their total duration on the target + * track + every sync-locked track (backend `InsertClips` / upstream + * `rippleInsertClips`). The overwrite-drop counterpart is {@link addClips}. */ +export async function insertClips(trackIndex: number, atFrame: number, entries: ClipEntryReq[]) { + if (entries.length === 0) return; + return applyAndRefresh({ type: "insertClips", trackIndex, atFrame, entries }); +} + +/** Build the ripple-insert plan for a media item dropped at `atFrame` over + * `preferredTrackIndex` (wires the pure `buildInsertPlan` with the store's + * transform-fit + still-image default). Returns null when no compatible target + * track exists — the caller then falls back to the overwrite add. */ +export function buildMediaInsertPlan( + timeline: Timeline, + item: MediaItem, + atFrame: number, + preferredTrackIndex: number | null, +): InsertPlan | null { + return buildInsertPlan( + timeline, + item, + atFrame, + preferredTrackIndex, + fitTransformForMedia, + DEFAULT_IMAGE_SECONDS, + ); +} + export async function moveClips(moves: ClipMoveReq[]) { if (moves.length === 0) return; await applyAndRefresh({ type: "moveClips", moves }); @@ -409,6 +442,83 @@ export async function rippleDeleteSelectedClips() { ui.clearSelection(); } +/** Ripple-delete the marked in/out range on the anchor clip's track (⇧⌫ when a + * valid range is marked AND a clip is selected). Mirrors upstream + * `rippleDeleteRanges(anchorClipId:)` → `rippleDeleteRangesOnTrack(trackIndex: + * anchorLoc.trackIndex, …)`: the range is cut from the selected clip's track, + * gaps close, linked A/V partners are cut, and sync-locked followers shift + * (the core refuses if a follower would collide). No-op without a valid range + * or a selected clip. Returns true when a delete was issued. */ +export async function rippleDeleteMarkedRange(): Promise { + const ui = useEditorUiStore.getState(); + const range = validRange(ui.selectedTimelineRange); + if (!range) return false; + // Anchor = the selected clip's track (upstream `rippleDeleteRanges(anchorClipId:)`). + const anchorId = liveSelectedClipIds()[0]; + if (!anchorId) return false; + const timeline = useProjectStore.getState().timeline; + let trackIndex = -1; + for (let ti = 0; ti < timeline.tracks.length && trackIndex < 0; ti++) { + if (timeline.tracks[ti].clips.some((c) => c.id === anchorId)) trackIndex = ti; + } + if (trackIndex < 0) return false; + try { + await rippleDeleteRanges(trackIndex, [{ start: range.startFrame, end: range.endFrame }]); + if (isTauri) await forceRefresh(); + } catch (err) { + ui.pushToast(`删除失败 / Delete failed: ${err instanceof Error ? err.message : String(err)}`); + } + ui.clearTimelineRange(); + ui.clearSelection(); + return true; +} + +/** Ripple-close the selected gap (⇧⌫ on a selected gap): remove the empty span + * and shift the gap track's later clips + every sync-locked follower left by the + * gap length (backend `RippleDeleteRanges` on the gap's track; upstream + * `rippleDeleteSelectedGap`). No-op without a selected gap. Returns true when a + * delete was issued. */ +export async function rippleDeleteSelectedGap(): Promise { + const ui = useEditorUiStore.getState(); + const gap = ui.selectedGap; + if (!gap || gap.endFrame <= gap.startFrame) return false; + // Guard: an out-of-band edit may have filled the gap (upstream re-checks). + const timeline = useProjectStore.getState().timeline; + const track = timeline.tracks[gap.trackIndex]; + if ( + !track || + track.clips.some((c) => c.startFrame < gap.endFrame && c.startFrame + c.durationFrames > gap.startFrame) + ) { + ui.selectGap(null); + return false; + } + try { + await rippleDeleteRanges(gap.trackIndex, [{ start: gap.startFrame, end: gap.endFrame }]); + if (isTauri) await forceRefresh(); + } catch (err) { + ui.pushToast(`删除失败 / Delete failed: ${err instanceof Error ? err.message : String(err)}`); + } + ui.selectGap(null); + return true; +} + +/** Nudge the selected clips by `deltaFrames` along the timeline (OpenTake + * extension: , / . keys, ±5 with Shift). Preserves each clip's track, floors + * the group at frame 0, and moves linked partners together (the selection is + * expanded to full link groups, then routed through `moveClips`). No-op when + * nothing is selected or the floored delta is zero. */ +export async function nudgeSelectedClips(deltaFrames: number) { + const ui = useEditorUiStore.getState(); + if (ui.selectedClipIds.size === 0) return; + const timeline = useProjectStore.getState().timeline; + // Linked partners travel together (the backend moveClips does NOT auto-expand + // link groups — the drag path expands them too). + const expanded = expandLinkGroup(timeline, ui.selectedClipIds); + const moves = planNudge(timeline, expanded, deltaFrames); + if (moves.length === 0) return; + await moveClips(moves); +} + // MARK: - Media -> timeline (drag and drop) /** Stills get a fixed default duration (upstream `Constants.defaultImageDuration` diff --git a/web/src/store/uiStore.ts b/web/src/store/uiStore.ts index f425042..abed560 100644 --- a/web/src/store/uiStore.ts +++ b/web/src/store/uiStore.ts @@ -9,6 +9,8 @@ import { ZOOM } from "../lib/theme"; import { useProjectStore } from "./projectStore"; import { totalFrames } from "../lib/geometry"; import type { CropAspectLock } from "../lib/cropOverlay"; +import { withRangeStart, withRangeEnd, type TimelineRange } from "../lib/timelineRange"; +import type { GapSelection } from "../lib/timelineGap"; export type Panel = "agent" | "media" | "preview" | "inspector" | "timeline"; /** Top-level app view (SPEC: 启动先进主页). The editor is one of three views; @@ -78,6 +80,13 @@ interface UiState { selectedMediaAssetIds: Set; selectedFolderIds: Set; isMarqueeSelecting: boolean; + /** Marked in/out timeline range (I/O keys, upstream `selectedTimelineRange`). + * Raw endpoints — `startFrame` may exceed `endFrame` mid-mark; consumers gate + * through `validRange`. `null` when no range is marked. */ + selectedTimelineRange: TimelineRange | null; + /** Selected empty gap between clips on one track (upstream `selectedGap`). + * Mutually exclusive with clip selection. `null` when no gap is selected. */ + selectedGap: GapSelection | null; // Timeline view zoomScale: number; @@ -154,6 +163,17 @@ interface UiState { selectMediaAssets: (ids: Set) => void; clearMediaSelection: () => void; + /** Mark the range START at `frame` (upstream `markTimelineRangeStart`). Also + * clears any clip / gap selection (the range is its own selection mode). */ + markRangeStart: (frame: number) => void; + /** Mark the range END at `frame` (upstream `markTimelineRangeEnd`). */ + markRangeEnd: (frame: number) => void; + /** Clear the marked range (upstream `clearTimelineRange`, e.g. on Escape). */ + clearTimelineRange: () => void; + /** Select an empty gap (upstream sets `selectedGap`). Clears clip selection — + * gap and clip selection are mutually exclusive. `null` deselects the gap. */ + selectGap: (gap: GapSelection | null) => void; + setZoomScale: (zoom: number) => void; setMinZoomScale: (zoom: number) => void; setScroll: (left: number, top: number) => void; @@ -199,6 +219,8 @@ export const useEditorUiStore = create((set, get) => ({ selectedMediaAssetIds: new Set(), selectedFolderIds: new Set(), isMarqueeSelecting: false, + selectedTimelineRange: null, + selectedGap: null, zoomScale: ZOOM.default, minZoomScale: 0.05, @@ -271,13 +293,40 @@ export const useEditorUiStore = create((set, get) => ({ // Selection change ends crop editing (InspectorView.swift:60-61,90: // `resolvePreferredTab()`, called on every `selectedClipIds` change, // unconditionally clears `cropEditingActive`). - selectClips: (selectedClipIds) => set({ selectedClipIds, cropEditingActive: false }), + // Selecting clips clears any gap selection (upstream: a clip mousedown sets + // `selectedGap = nil` — the two are mutually exclusive). + selectClips: (selectedClipIds) => + set({ selectedClipIds, selectedGap: null, cropEditingActive: false }), clearSelection: () => - set({ selectedClipIds: new Set(), isMarqueeSelecting: false, cropEditingActive: false }), + set({ + selectedClipIds: new Set(), + selectedGap: null, + isMarqueeSelecting: false, + cropEditingActive: false, + }), selectMediaAssets: (selectedMediaAssetIds) => set({ selectedMediaAssetIds }), clearMediaSelection: () => set({ selectedMediaAssetIds: new Set() }), setPreviewMedia: (previewMediaId) => set({ previewMediaId }), + // Marking a range is its own selection mode: upstream's ruler range gesture + // (`beginTimelineRangeSelection`) clears clip + gap selection when it starts. + markRangeStart: (frame) => + set((s) => ({ + selectedTimelineRange: withRangeStart(s.selectedTimelineRange, frame), + selectedClipIds: new Set(), + selectedGap: null, + })), + markRangeEnd: (frame) => + set((s) => ({ + selectedTimelineRange: withRangeEnd(s.selectedTimelineRange, frame), + selectedClipIds: new Set(), + selectedGap: null, + })), + clearTimelineRange: () => set({ selectedTimelineRange: null }), + // Selecting a gap clears clip selection (mutual exclusivity, upstream behavior). + selectGap: (selectedGap) => + set(selectedGap ? { selectedGap, selectedClipIds: new Set() } : { selectedGap: null }), + setZoomScale: (zoomScale) => set({ zoomScale: Math.max(get().minZoomScale, Math.min(ZOOM.max, zoomScale)) }), setMinZoomScale: (minZoomScale) => set({ minZoomScale }),