feat: adjustable clip list layout (bottom/left sidebar)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
229
src/App.svelte
229
src/App.svelte
@@ -10,7 +10,7 @@
|
||||
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
||||
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
||||
import ProcessingModal from '$lib/components/ProcessingModal.svelte';
|
||||
import { loadPreferences } from '$lib/stores/preferences.svelte';
|
||||
import { loadPreferences, preferences, setClipListPosition, setClipListWidth } from '$lib/stores/preferences.svelte';
|
||||
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
|
||||
import { adjustShuttle, resetShuttleRate } from '$lib/transport/playback';
|
||||
import { session } from '$lib/stores/videoSession.svelte';
|
||||
@@ -32,6 +32,11 @@
|
||||
let resizeStartHeight = $state(0);
|
||||
let splitAreaEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
let isSidebarResizing = $state(false);
|
||||
let sidebarResizeStartX = $state(0);
|
||||
let sidebarResizeStartWidth = $state(0);
|
||||
let windowWidth = $state(window.innerWidth);
|
||||
|
||||
$effect(() => {
|
||||
loadPreferences();
|
||||
});
|
||||
@@ -74,6 +79,33 @@
|
||||
isResizing = false;
|
||||
}
|
||||
|
||||
function toggleClipListPosition() {
|
||||
const next = preferences.clipListPosition === 'bottom' ? 'left' : 'bottom';
|
||||
setClipListPosition(next);
|
||||
}
|
||||
|
||||
function handleSidebarResizeStart(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
isSidebarResizing = true;
|
||||
sidebarResizeStartX = e.clientX;
|
||||
sidebarResizeStartWidth = preferences.clipListWidth;
|
||||
}
|
||||
|
||||
function handleSidebarResizeMove(e: MouseEvent) {
|
||||
if (!isSidebarResizing) return;
|
||||
const delta = e.clientX - sidebarResizeStartX;
|
||||
const maxWidth = Math.round(windowWidth * 0.4);
|
||||
const newWidth = Math.max(150, Math.min(maxWidth, sidebarResizeStartWidth + delta));
|
||||
preferences.clipListWidth = newWidth;
|
||||
}
|
||||
|
||||
function handleSidebarResizeEnd() {
|
||||
if (isSidebarResizing) {
|
||||
isSidebarResizing = false;
|
||||
setClipListWidth(preferences.clipListWidth);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||
@@ -156,8 +188,17 @@
|
||||
|
||||
<svelte:window
|
||||
onkeydown={handleGlobalKeydown}
|
||||
onmousemove={handleResizeMove}
|
||||
onmouseup={handleResizeEnd}
|
||||
onmousemove={(e) => {
|
||||
handleResizeMove(e);
|
||||
handleSidebarResizeMove(e);
|
||||
}}
|
||||
onmouseup={() => {
|
||||
handleResizeEnd();
|
||||
handleSidebarResizeEnd();
|
||||
}}
|
||||
onresize={() => {
|
||||
windowWidth = window.innerWidth;
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if showSetupWizard}
|
||||
@@ -183,43 +224,115 @@
|
||||
<div class="app-shell">
|
||||
<header class="toolbar">
|
||||
<UrlInput />
|
||||
<button
|
||||
class="layout-btn"
|
||||
onclick={toggleClipListPosition}
|
||||
title={preferences.clipListPosition === 'bottom'
|
||||
? 'Move clip list to sidebar'
|
||||
: 'Move clip list to bottom'}
|
||||
>
|
||||
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
|
||||
</button>
|
||||
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
|
||||
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<VideoPlayer />
|
||||
<TransportControls bind:this={transportControls} />
|
||||
<div class="timeline-clip-area" bind:this={splitAreaEl}>
|
||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||||
<Timeline />
|
||||
{#if preferences.clipListPosition === 'bottom'}
|
||||
<main class="content">
|
||||
<VideoPlayer />
|
||||
<TransportControls bind:this={transportControls} />
|
||||
<div class="timeline-clip-area" bind:this={splitAreaEl}>
|
||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||||
<Timeline />
|
||||
</div>
|
||||
<div
|
||||
class="resize-handle"
|
||||
class:active={isResizing}
|
||||
role="slider"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize timeline and clip list"
|
||||
aria-valuenow={timelineHeight}
|
||||
aria-valuemin={40}
|
||||
aria-valuemax={500}
|
||||
tabindex="0"
|
||||
onmousedown={handleResizeStart}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.min(500, timelineHeight + 10);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.max(40, timelineHeight - 10);
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
<div class="cliplist-pane">
|
||||
<ClipList onExport={() => (showExportDialog = true)} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="resize-handle"
|
||||
class:active={isResizing}
|
||||
role="slider"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize timeline and clip list"
|
||||
aria-valuenow={timelineHeight}
|
||||
aria-valuemin={40}
|
||||
aria-valuemax={500}
|
||||
tabindex="0"
|
||||
onmousedown={handleResizeStart}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.min(500, timelineHeight + 10);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.max(40, timelineHeight - 10);
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
<div class="cliplist-pane">
|
||||
<ClipList onExport={() => (showExportDialog = true)} />
|
||||
</main>
|
||||
{:else}
|
||||
<main class="content content-left">
|
||||
<div class="upper-section">
|
||||
<div class="cliplist-sidebar" style="width: {preferences.clipListWidth}px">
|
||||
<ClipList onExport={() => (showExportDialog = true)} position="left" />
|
||||
</div>
|
||||
<div
|
||||
class="sidebar-resize-handle"
|
||||
class:active={isSidebarResizing}
|
||||
role="slider"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize clip list sidebar"
|
||||
aria-valuenow={preferences.clipListWidth}
|
||||
aria-valuemin={150}
|
||||
aria-valuemax={Math.round(windowWidth * 0.4)}
|
||||
tabindex="0"
|
||||
onmousedown={handleSidebarResizeStart}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setClipListWidth(
|
||||
Math.min(Math.round(windowWidth * 0.4), preferences.clipListWidth + 10),
|
||||
);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
setClipListWidth(Math.max(150, preferences.clipListWidth - 10));
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
<div class="right-column">
|
||||
<VideoPlayer />
|
||||
<TransportControls bind:this={transportControls} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div class="timeline-clip-area" bind:this={splitAreaEl}>
|
||||
<div
|
||||
class="resize-handle"
|
||||
class:active={isResizing}
|
||||
role="slider"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize upper section and timeline"
|
||||
aria-valuenow={timelineHeight}
|
||||
aria-valuemin={40}
|
||||
aria-valuemax={500}
|
||||
tabindex="0"
|
||||
onmousedown={handleResizeStart}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.min(500, timelineHeight + 10);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.max(40, timelineHeight - 10);
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||||
<Timeline />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
||||
</div>
|
||||
@@ -279,6 +392,54 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upper-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cliplist-sidebar {
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.sidebar-resize-handle {
|
||||
width: 5px;
|
||||
background: var(--border);
|
||||
cursor: ew-resize;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle:hover,
|
||||
.sidebar-resize-handle.active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.right-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-btn {
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.about-btn {
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
import { formatTime, parseTime } from '$lib/utils/time';
|
||||
import { session } from '$lib/stores/videoSession.svelte';
|
||||
|
||||
let { onExport }: { onExport?: () => void } = $props();
|
||||
let {
|
||||
onExport,
|
||||
position = 'bottom',
|
||||
}: { onExport?: () => void; position?: 'bottom' | 'left' } = $props();
|
||||
|
||||
let clips = $derived(clipStore.clips);
|
||||
let selectedId = $derived(clipStore.selectedClipId);
|
||||
@@ -45,6 +48,7 @@
|
||||
|
||||
<div
|
||||
class="clip-list"
|
||||
class:sidebar={position === 'left'}
|
||||
role="listbox"
|
||||
tabindex="0"
|
||||
onclick={handleContainerClick}
|
||||
@@ -132,6 +136,11 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clip-list.sidebar {
|
||||
border-top: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -47,12 +47,16 @@ export interface Preferences {
|
||||
outputDirectory: string;
|
||||
cookieSource: CookieSource;
|
||||
captionSettings: CaptionSettings;
|
||||
clipListPosition: 'bottom' | 'left';
|
||||
clipListWidth: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFERENCES: Preferences = {
|
||||
outputDirectory: '~/',
|
||||
cookieSource: { type: 'browser', browser: 'firefox' },
|
||||
captionSettings: { ...DEFAULT_CAPTION_SETTINGS },
|
||||
clipListPosition: 'bottom',
|
||||
clipListWidth: 220,
|
||||
};
|
||||
|
||||
let store: Store | null = null;
|
||||
@@ -73,6 +77,10 @@ export async function loadPreferences(): Promise<void> {
|
||||
if (savedDir) preferences.outputDirectory = savedDir;
|
||||
if (savedCookie) preferences.cookieSource = savedCookie;
|
||||
if (savedCaptions) preferences.captionSettings = { ...DEFAULT_CAPTION_SETTINGS, ...savedCaptions };
|
||||
const savedClipListPosition = await store.get<'bottom' | 'left'>('clipListPosition');
|
||||
const savedClipListWidth = await store.get<number>('clipListWidth');
|
||||
if (savedClipListPosition) preferences.clipListPosition = savedClipListPosition;
|
||||
if (savedClipListWidth) preferences.clipListWidth = savedClipListWidth;
|
||||
})();
|
||||
await loadPromise;
|
||||
}
|
||||
@@ -85,6 +93,8 @@ export async function savePreferences(): Promise<void> {
|
||||
await store.set('outputDirectory', preferences.outputDirectory);
|
||||
await store.set('cookieSource', preferences.cookieSource);
|
||||
await store.set('captionSettings', preferences.captionSettings);
|
||||
await store.set('clipListPosition', preferences.clipListPosition);
|
||||
await store.set('clipListWidth', preferences.clipListWidth);
|
||||
await store.save();
|
||||
}
|
||||
|
||||
@@ -102,3 +112,13 @@ export async function setCaptionSettings(settings: Partial<CaptionSettings>): Pr
|
||||
Object.assign(preferences.captionSettings, settings);
|
||||
await savePreferences();
|
||||
}
|
||||
|
||||
export async function setClipListPosition(position: 'bottom' | 'left'): Promise<void> {
|
||||
preferences.clipListPosition = position;
|
||||
await savePreferences();
|
||||
}
|
||||
|
||||
export async function setClipListWidth(width: number): Promise<void> {
|
||||
preferences.clipListWidth = width;
|
||||
await savePreferences();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user