feat: timeline right-click context menu with mark/seek/delete actions
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
206
src/lib/components/TimelineContextMenu.svelte
Normal file
206
src/lib/components/TimelineContextMenu.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user