feat: timeline right-click context menu with mark/seek/delete actions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 16:53:43 -04:00
parent 27c199e090
commit 8c271773bd
2 changed files with 273 additions and 0 deletions

View File

@@ -1,9 +1,14 @@
<script lang="ts">
import TimelineContextMenu from '$lib/components/TimelineContextMenu.svelte';
import type { ContextMenuAction } from '$lib/components/TimelineContextMenu.svelte';
import { session } from '$lib/stores/videoSession.svelte';
import {
clipStore,
selectClip,
updateClip,
markInPoint,
markOutPoint,
removeClip,
} from '$lib/stores/clips.svelte';
import { drawTimeline, type TimelineState } from '$lib/timeline/renderer';
import {
@@ -37,6 +42,11 @@
let minimapDragStartX = $state(0);
let minimapDragStartVisibleStart = $state(0);
let mouseX = $state<number | null>(null);
let contextMenu = $state<{
x: number;
y: number;
time: number;
} | null>(null);
let isZoomed = $derived(timelineState.zoom > 1.01);
@@ -235,7 +245,49 @@
}
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
const x = getCanvasX(e);
const time = computeClickTime(x, timelineState, session.duration);
seekTo(time);
contextMenu = {
x: e.clientX,
y: e.clientY,
time,
};
}
function handleContextMenuAction(action: ContextMenuAction) {
if (!contextMenu) return;
switch (action.type) {
case 'mark-in':
markInPoint(contextMenu.time);
break;
case 'mark-out':
markOutPoint(contextMenu.time);
break;
case 'go-to-clip-start': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) seekTo(clip.startTime);
break;
}
case 'go-to-clip-end': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) seekTo(clip.endTime);
break;
}
case 'delete-clip':
if (clipStore.selectedClipId) removeClip(clipStore.selectedClipId);
break;
default: {
const _exhaustive: never = action;
void _exhaustive;
}
}
}
function handleMouseDown(e: MouseEvent) {
contextMenu = null;
const x = getCanvasX(e);
const y = getCanvasY(e);
@@ -298,6 +350,7 @@
function handleWheel(e: WheelEvent) {
e.preventDefault();
contextMenu = null;
if (e.deltaX !== 0) {
const result = panBy(timelineState, e.deltaX, session.duration);
@@ -395,10 +448,24 @@
onmouseup={handleMouseUp}
onmouseleave={handleMouseLeave}
onwheel={handleWheel}
oncontextmenu={handleContextMenu}
></canvas>
</div>
</div>
{#if contextMenu}
<TimelineContextMenu
x={contextMenu.x}
y={contextMenu.y}
time={contextMenu.time}
selectedClipId={clipStore.selectedClipId}
onAction={handleContextMenuAction}
onClose={() => {
contextMenu = null;
}}
/>
{/if}
<svelte:window
onmousemove={handleMinimapMouseMove}
onmouseup={handleMinimapMouseUp}

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import { formatTime } from '$lib/utils/time';
export type ContextMenuAction =
| { type: 'mark-in' }
| { type: 'mark-out' }
| { type: 'go-to-clip-start' }
| { type: 'go-to-clip-end' }
| { type: 'delete-clip' };
let {
x,
y,
time,
selectedClipId,
onAction,
onClose,
}: {
x: number;
y: number;
time: number;
selectedClipId: string | null;
onAction: (action: ContextMenuAction) => void;
onClose: () => void;
} = $props();
let menuEl = $state<HTMLDivElement | null>(null);
function handleAction(action: ContextMenuAction) {
onAction(action);
onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
function handleClickOutside(e: MouseEvent) {
if (menuEl && !menuEl.contains(e.target as Node)) {
onClose();
}
}
let adjustedX = $state(0);
let adjustedY = $state(0);
$effect(() => {
const posX = x;
const posY = y;
if (!menuEl) {
adjustedX = posX;
adjustedY = posY;
return;
}
const rect = menuEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let newX = posX;
let newY = posY;
if (posX + rect.width > vw) {
newX = posX - rect.width;
}
if (posY + rect.height > vh) {
newY = posY - rect.height;
}
adjustedX = Math.max(0, newX);
adjustedY = Math.max(0, newY);
});
</script>
<svelte:window
onmousedown={handleClickOutside}
onkeydown={handleKeydown}
/>
<div
class="context-menu"
bind:this={menuEl}
style="left: {adjustedX}px; top: {adjustedY}px;"
role="menu"
>
<div class="menu-header">{formatTime(time)}</div>
<button
class="menu-item"
role="menuitem"
onclick={() => handleAction({ type: 'mark-in' })}
>
<span class="label">Mark In</span>
<span class="shortcut">I</span>
</button>
<button
class="menu-item"
role="menuitem"
onclick={() => handleAction({ type: 'mark-out' })}
>
<span class="label">Mark Out</span>
<span class="shortcut">O</span>
</button>
<div class="separator"></div>
<button
class="menu-item"
class:disabled={!selectedClipId}
role="menuitem"
disabled={!selectedClipId}
onclick={() => handleAction({ type: 'go-to-clip-start' })}
>
<span class="label">Go to Clip Start</span>
</button>
<button
class="menu-item"
class:disabled={!selectedClipId}
role="menuitem"
disabled={!selectedClipId}
onclick={() => handleAction({ type: 'go-to-clip-end' })}
>
<span class="label">Go to Clip End</span>
</button>
{#if selectedClipId}
<div class="separator"></div>
<button
class="menu-item danger"
role="menuitem"
onclick={() => handleAction({ type: 'delete-clip' })}
>
<span class="label">Delete Clip</span>
<span class="shortcut">⌫</span>
</button>
{/if}
</div>
<style>
.context-menu {
position: fixed;
z-index: 1000;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 0;
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
font-size: 13px;
}
.menu-header {
padding: 4px 12px 2px;
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
border-radius: 0;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
}
.menu-item:hover:not(:disabled) {
background: var(--accent);
color: var(--bg-primary);
}
.menu-item:disabled {
color: var(--text-muted);
cursor: default;
opacity: 0.5;
}
.menu-item.danger:hover {
background: var(--danger);
color: var(--bg-primary);
}
.shortcut {
font-size: 11px;
color: var(--text-muted);
margin-left: 16px;
}
.menu-item:hover:not(:disabled) .shortcut {
color: inherit;
}
.separator {
height: 1px;
background: var(--border);
margin: 4px 0;
}
</style>