1061 lines
27 KiB
Markdown
1061 lines
27 KiB
Markdown
|
|
# v0.1.3 Polish & Tweaks — Implementation Plan
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Ship three UI polish features — adjustable clip-list layout, magnifying timestamp labels, and a timeline right-click context menu — as v0.1.3.
|
||
|
|
|
||
|
|
**Architecture:** All changes are frontend-only (Svelte + TypeScript + canvas). The layout feature restructures `App.svelte` with a preference-driven conditional layout. The timestamp feature enhances canvas rendering in `renderer.ts`. The context menu is a new Svelte component wired into `Timeline.svelte`.
|
||
|
|
|
||
|
|
**Tech Stack:** Svelte 5, TypeScript, HTML Canvas API, Tauri plugin-store for persistence.
|
||
|
|
|
||
|
|
## Global Constraints
|
||
|
|
|
||
|
|
- Version: 0.1.3
|
||
|
|
- No new npm/cargo dependencies
|
||
|
|
- No Rust backend changes
|
||
|
|
- All CSS uses existing CSS custom properties from `src/app.css`
|
||
|
|
- Imports at top of file, never inline
|
||
|
|
- Exhaustive `switch` with `never` check in default cases for discriminated unions
|
||
|
|
- The project is currently clean — any warnings or build errors are new regressions
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 1: Version Bump
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `VERSION` (root — create if missing, content: `0.1.3`)
|
||
|
|
- Run: `scripts/bump-version.sh`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: nothing
|
||
|
|
- Produces: all manifests at version `0.1.3`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Bump version via script**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd /Users/erikfredericks/dev-ai/one-offs/gui-video-clipper
|
||
|
|
echo "0.1.3" > VERSION
|
||
|
|
bash scripts/bump-version.sh
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected output:
|
||
|
|
```
|
||
|
|
Bumping to version 0.1.3
|
||
|
|
✓ package.json
|
||
|
|
✓ src-tauri/Cargo.toml
|
||
|
|
✓ src-tauri/tauri.conf.json
|
||
|
|
✓ src-tauri/Cargo.lock
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify no stale 0.1.2 references**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
grep -r "0\.1\.2" package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: no output (no matches).
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add VERSION package.json package-lock.json src-tauri/tauri.conf.json src-tauri/Cargo.toml src-tauri/Cargo.lock
|
||
|
|
git commit -m "chore: bump version to 0.1.3"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: Layout Preference & Toggle Button
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `src/lib/stores/preferences.svelte.ts` — add `clipListPosition` and `clipListWidth` fields
|
||
|
|
- Modify: `src/App.svelte` — add toggle button to toolbar, restructure content for dual layout
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: existing `preferences` store, `savePreferences()`, `loadPreferences()`
|
||
|
|
- Produces: `preferences.clipListPosition` (`'bottom' | 'left'`), `preferences.clipListWidth` (`number`)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add preference fields**
|
||
|
|
|
||
|
|
In `src/lib/stores/preferences.svelte.ts`, add `clipListPosition` and `clipListWidth` to the `Preferences` interface and defaults:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
Update `loadPreferences()` to load the new fields:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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;
|
||
|
|
```
|
||
|
|
|
||
|
|
Update `savePreferences()` to save them:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
await store.set('clipListPosition', preferences.clipListPosition);
|
||
|
|
await store.set('clipListWidth', preferences.clipListWidth);
|
||
|
|
```
|
||
|
|
|
||
|
|
Add a convenience setter:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add toggle button to toolbar in App.svelte**
|
||
|
|
|
||
|
|
Import the new setter at the top of `src/App.svelte`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { preferences, setClipListPosition } from '$lib/stores/preferences.svelte';
|
||
|
|
```
|
||
|
|
|
||
|
|
(The existing `loadPreferences` import stays.)
|
||
|
|
|
||
|
|
Add a toggle function:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
function toggleClipListPosition() {
|
||
|
|
const next = preferences.clipListPosition === 'bottom' ? 'left' : 'bottom';
|
||
|
|
setClipListPosition(next);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add the button in the toolbar, before the about/prefs buttons:
|
||
|
|
|
||
|
|
```svelte
|
||
|
|
<button
|
||
|
|
class="layout-btn"
|
||
|
|
onclick={toggleClipListPosition}
|
||
|
|
title={preferences.clipListPosition === 'bottom' ? 'Move clip list to sidebar' : 'Move clip list to bottom'}
|
||
|
|
>
|
||
|
|
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
|
||
|
|
</button>
|
||
|
|
```
|
||
|
|
|
||
|
|
Add styling:
|
||
|
|
|
||
|
|
```css
|
||
|
|
.layout-btn {
|
||
|
|
font-size: 16px;
|
||
|
|
padding: 4px 8px;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Restructure content area for 'left' mode**
|
||
|
|
|
||
|
|
Replace the `<main class="content">` block in `App.svelte` with a conditional layout. The key structural change:
|
||
|
|
|
||
|
|
**Bottom mode** (existing behavior — unchanged):
|
||
|
|
```svelte
|
||
|
|
<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>
|
||
|
|
</main>
|
||
|
|
```
|
||
|
|
|
||
|
|
**Left mode:**
|
||
|
|
```svelte
|
||
|
|
<main class="content content-left">
|
||
|
|
<div class="upper-section">
|
||
|
|
<div class="cliplist-sidebar" style="width: {preferences.clipListWidth}px">
|
||
|
|
<ClipList onExport={() => (showExportDialog = true)} />
|
||
|
|
</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 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>
|
||
|
|
```
|
||
|
|
|
||
|
|
Note: in left mode the resize handle sits *above* the timeline pane (it's between the upper section and the timeline). The `timeline-clip-area` now only contains the resize handle and the timeline — no clip list.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Add sidebar resize logic**
|
||
|
|
|
||
|
|
Add state and handlers for the horizontal sidebar resize in `App.svelte`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
let isSidebarResizing = $state(false);
|
||
|
|
let sidebarResizeStartX = $state(0);
|
||
|
|
let sidebarResizeStartWidth = $state(0);
|
||
|
|
let windowWidth = $state(window.innerWidth);
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Update the existing `handleResizeMove` to also handle sidebar resize — or better, update the `<svelte:window>` handlers:
|
||
|
|
|
||
|
|
```svelte
|
||
|
|
<svelte:window
|
||
|
|
onkeydown={handleGlobalKeydown}
|
||
|
|
onmousemove={(e) => { handleResizeMove(e); handleSidebarResizeMove(e); }}
|
||
|
|
onmouseup={() => { handleResizeEnd(); handleSidebarResizeEnd(); }}
|
||
|
|
onresize={() => { windowWidth = window.innerWidth; }}
|
||
|
|
/>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Add CSS for left mode**
|
||
|
|
|
||
|
|
```css
|
||
|
|
.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;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Also, the `ClipList` component has a `border-top` that makes sense in bottom mode but not in left mode. Add a `position` prop to `ClipList.svelte`:
|
||
|
|
|
||
|
|
In `src/lib/components/ClipList.svelte`, change the props:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
let { onExport, position = 'bottom' }: { onExport?: () => void; position?: 'bottom' | 'left' } = $props();
|
||
|
|
```
|
||
|
|
|
||
|
|
Add a conditional class on the container:
|
||
|
|
|
||
|
|
```svelte
|
||
|
|
<div
|
||
|
|
class="clip-list"
|
||
|
|
class:sidebar={position === 'left'}
|
||
|
|
...
|
||
|
|
>
|
||
|
|
```
|
||
|
|
|
||
|
|
Add CSS:
|
||
|
|
|
||
|
|
```css
|
||
|
|
.clip-list.sidebar {
|
||
|
|
border-top: none;
|
||
|
|
height: 100%;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Pass the prop in `App.svelte`:
|
||
|
|
|
||
|
|
```svelte
|
||
|
|
<!-- In left mode -->
|
||
|
|
<ClipList onExport={() => (showExportDialog = true)} position="left" />
|
||
|
|
<!-- In bottom mode -->
|
||
|
|
<ClipList onExport={() => (showExportDialog = true)} />
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Build and verify**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd /Users/erikfredericks/dev-ai/one-offs/gui-video-clipper
|
||
|
|
npm run check
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: no errors or warnings.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm run tauri dev
|
||
|
|
```
|
||
|
|
|
||
|
|
Verify:
|
||
|
|
1. Default mode is 'bottom' — layout unchanged from v0.1.2
|
||
|
|
2. Click the layout toggle button — clip list moves to left sidebar
|
||
|
|
3. Sidebar is resizable by dragging the handle
|
||
|
|
4. Timeline stays full width in both modes
|
||
|
|
5. Toggle persists after reload
|
||
|
|
6. No visual glitches when switching modes
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A
|
||
|
|
git commit -m "feat: adjustable clip list layout (bottom/left sidebar)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: Timeline Timestamp Label Magnification
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `src/lib/timeline/renderer.ts` — increase base font, add magnification to `drawTimeTicks()`
|
||
|
|
- Modify: `src/lib/components/Timeline.svelte` — track `mouseX`, pass to draw functions
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: existing `drawTimeline()` signature, `TimelineState`
|
||
|
|
- Produces: updated `drawTimeline()` with new `mouseX` parameter (`number | null`)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add mouseX parameter to drawTimeline and drawTimeTicks**
|
||
|
|
|
||
|
|
In `src/lib/timeline/renderer.ts`, update the `drawTimeline` signature to accept `mouseX`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export function drawTimeline(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
state: TimelineState,
|
||
|
|
currentTime: number,
|
||
|
|
duration: number,
|
||
|
|
clips: Clip[] = [],
|
||
|
|
selectedClipId: string | null = null,
|
||
|
|
pendingInPoint: number | null = null,
|
||
|
|
waveform: WaveformData = { tiers: null },
|
||
|
|
thumbnailSpritesheets: ThumbnailSpritesheet[] = [],
|
||
|
|
hasVideo: boolean = false,
|
||
|
|
mouseX: number | null = null
|
||
|
|
): void {
|
||
|
|
const { width, height } = state;
|
||
|
|
ctx.clearRect(0, 0, width, height);
|
||
|
|
|
||
|
|
ctx.fillStyle = '#181825';
|
||
|
|
ctx.fillRect(0, 0, width, height);
|
||
|
|
|
||
|
|
drawTimeTicks(ctx, state, duration, mouseX);
|
||
|
|
|
||
|
|
// ... rest unchanged
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Implement magnification in drawTimeTicks**
|
||
|
|
|
||
|
|
Replace the `drawTimeTicks` function in `src/lib/timeline/renderer.ts`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const BASE_FONT_SIZE = 12;
|
||
|
|
const MAGNIFY_BONUS = 6;
|
||
|
|
const MAGNIFY_RADIUS = 80;
|
||
|
|
|
||
|
|
function drawTimeTicks(
|
||
|
|
ctx: CanvasRenderingContext2D,
|
||
|
|
state: TimelineState,
|
||
|
|
duration: number,
|
||
|
|
mouseX: number | null
|
||
|
|
): void {
|
||
|
|
const range = state.visibleEnd - state.visibleStart;
|
||
|
|
const tickInterval = getTickInterval(range, state.width);
|
||
|
|
const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval;
|
||
|
|
|
||
|
|
ctx.textAlign = 'center';
|
||
|
|
const baselineY = state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT + 14;
|
||
|
|
|
||
|
|
for (let t = startTick; t <= state.visibleEnd; t += tickInterval) {
|
||
|
|
if (t < 0 || t > duration) continue;
|
||
|
|
const x = timeToX(t, state);
|
||
|
|
|
||
|
|
// Tick mark
|
||
|
|
ctx.strokeStyle = '#45475a';
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.moveTo(x, state.height - CONTROLS_LANE_HEIGHT);
|
||
|
|
ctx.lineTo(x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT);
|
||
|
|
ctx.stroke();
|
||
|
|
|
||
|
|
// Compute magnified font size
|
||
|
|
let fontSize = BASE_FONT_SIZE;
|
||
|
|
if (mouseX !== null) {
|
||
|
|
const distance = Math.abs(x - mouseX);
|
||
|
|
if (distance < MAGNIFY_RADIUS) {
|
||
|
|
const scale = (1 + Math.cos(Math.PI * distance / MAGNIFY_RADIUS)) / 2;
|
||
|
|
fontSize = BASE_FONT_SIZE + MAGNIFY_BONUS * scale;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.fillStyle = '#6c7086';
|
||
|
|
ctx.font = `${Math.round(fontSize)}px -apple-system, sans-serif`;
|
||
|
|
|
||
|
|
const label = formatTickLabel(t);
|
||
|
|
ctx.fillText(label, x, baselineY);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Key changes from original:
|
||
|
|
- Base font size: `10px` → `12px`
|
||
|
|
- Baseline Y position: shifted down by 2px to accommodate the larger base font (`+ 12` → `+ 14`)
|
||
|
|
- When `mouseX` is non-null, each label computes its distance from the cursor and applies a cosine falloff scale to the font size
|
||
|
|
- The `baselineY` stays constant — larger fonts grow upward from the same baseline, avoiding jitter
|
||
|
|
|
||
|
|
- [ ] **Step 3: Track mouseX in Timeline.svelte**
|
||
|
|
|
||
|
|
In `src/lib/components/Timeline.svelte`, add the mouseX state:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
let mouseX = $state<number | null>(null);
|
||
|
|
```
|
||
|
|
|
||
|
|
Add a `mousemove` handler for tracking (the existing `handleMouseMove` only fires during drag — we need passive tracking too):
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
function handleMouseTrack(e: MouseEvent) {
|
||
|
|
mouseX = getCanvasX(e);
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleMouseLeave() {
|
||
|
|
handleMouseUp();
|
||
|
|
mouseX = null;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Update the canvas element events:
|
||
|
|
|
||
|
|
```svelte
|
||
|
|
<canvas
|
||
|
|
bind:this={canvas}
|
||
|
|
onmousedown={handleMouseDown}
|
||
|
|
onmousemove={(e) => { handleMouseMove(e); handleMouseTrack(e); }}
|
||
|
|
onmouseup={handleMouseUp}
|
||
|
|
onmouseleave={handleMouseLeave}
|
||
|
|
onwheel={handleWheel}
|
||
|
|
></canvas>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Pass mouseX to drawTimeline**
|
||
|
|
|
||
|
|
Update `drawMainCanvas()` in `Timeline.svelte`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
function drawMainCanvas() {
|
||
|
|
if (!canvas) return;
|
||
|
|
const ctx = canvas.getContext('2d');
|
||
|
|
if (!ctx) return;
|
||
|
|
ctx.save();
|
||
|
|
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||
|
|
drawTimeline(
|
||
|
|
ctx,
|
||
|
|
timelineState,
|
||
|
|
session.currentTime,
|
||
|
|
session.duration,
|
||
|
|
clipStore.clips,
|
||
|
|
clipStore.selectedClipId,
|
||
|
|
clipStore.pendingInPoint,
|
||
|
|
waveformData,
|
||
|
|
session.thumbnailSpritesheets,
|
||
|
|
session.status === 'ready',
|
||
|
|
mouseX
|
||
|
|
);
|
||
|
|
ctx.restore();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add `mouseX` to the unified redraw effect's dependency tracking:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
$effect(() => {
|
||
|
|
// ... existing void expressions ...
|
||
|
|
void mouseX;
|
||
|
|
requestDraw();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Build and verify**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm run check
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: no errors.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm run tauri dev
|
||
|
|
```
|
||
|
|
|
||
|
|
Verify:
|
||
|
|
1. Timestamp labels are visibly larger than before (12px base vs 10px)
|
||
|
|
2. Moving the mouse along the timeline causes nearby labels to smoothly enlarge
|
||
|
|
3. The label directly under the cursor is noticeably larger (~18px)
|
||
|
|
4. Labels far from the cursor stay at base size
|
||
|
|
5. Moving the mouse off the timeline returns all labels to base size
|
||
|
|
6. No jitter — labels grow upward from a fixed baseline
|
||
|
|
7. Performance is smooth — no noticeable lag when moving the mouse
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A
|
||
|
|
git commit -m "feat: larger timeline timestamps with mouse-proximity magnification"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: Timeline Right-Click Context Menu
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/lib/components/TimelineContextMenu.svelte`
|
||
|
|
- Modify: `src/lib/components/Timeline.svelte` — add `oncontextmenu` handler, render context menu
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `markInPoint()`, `markOutPoint()`, `removeClip()`, `selectClip()` from clips store; `clipStore.selectedClipId`, `clipStore.clips`; `seekTo()` from Timeline
|
||
|
|
- Produces: `TimelineContextMenu` component, `ContextMenuAction` type
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create TimelineContextMenu.svelte**
|
||
|
|
|
||
|
|
Create `src/lib/components/TimelineContextMenu.svelte`:
|
||
|
|
|
||
|
|
```svelte
|
||
|
|
<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();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Adjust position to keep menu within viewport
|
||
|
|
let adjustedX = $state(x);
|
||
|
|
let adjustedY = $state(y);
|
||
|
|
|
||
|
|
$effect(() => {
|
||
|
|
if (!menuEl) return;
|
||
|
|
const rect = menuEl.getBoundingClientRect();
|
||
|
|
const vw = window.innerWidth;
|
||
|
|
const vh = window.innerHeight;
|
||
|
|
|
||
|
|
let newX = x;
|
||
|
|
let newY = y;
|
||
|
|
|
||
|
|
if (x + rect.width > vw) {
|
||
|
|
newX = x - rect.width;
|
||
|
|
}
|
||
|
|
if (y + rect.height > vh) {
|
||
|
|
newY = y - 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>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Wire context menu into Timeline.svelte**
|
||
|
|
|
||
|
|
In `src/lib/components/Timeline.svelte`, add the imports and state:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import TimelineContextMenu from '$lib/components/TimelineContextMenu.svelte';
|
||
|
|
import type { ContextMenuAction } from '$lib/components/TimelineContextMenu.svelte';
|
||
|
|
import {
|
||
|
|
clipStore,
|
||
|
|
selectClip,
|
||
|
|
updateClip,
|
||
|
|
markInPoint,
|
||
|
|
markOutPoint,
|
||
|
|
removeClip,
|
||
|
|
} from '$lib/stores/clips.svelte';
|
||
|
|
```
|
||
|
|
|
||
|
|
(Merge with the existing clips import — add `markInPoint`, `markOutPoint`, `removeClip` to the existing destructure.)
|
||
|
|
|
||
|
|
Add context menu state:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
let contextMenu = $state<{
|
||
|
|
x: number;
|
||
|
|
y: number;
|
||
|
|
time: number;
|
||
|
|
} | null>(null);
|
||
|
|
```
|
||
|
|
|
||
|
|
Add the `contextmenu` handler:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add the action handler:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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.type;
|
||
|
|
void _exhaustive;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add `oncontextmenu` to the canvas element:
|
||
|
|
|
||
|
|
```svelte
|
||
|
|
<canvas
|
||
|
|
bind:this={canvas}
|
||
|
|
onmousedown={handleMouseDown}
|
||
|
|
onmousemove={(e) => { handleMouseMove(e); handleMouseTrack(e); }}
|
||
|
|
onmouseup={handleMouseUp}
|
||
|
|
onmouseleave={handleMouseLeave}
|
||
|
|
onwheel={handleWheel}
|
||
|
|
oncontextmenu={handleContextMenu}
|
||
|
|
></canvas>
|
||
|
|
```
|
||
|
|
|
||
|
|
Render the context menu conditionally, after the closing `</div>` of `.timeline-wrapper`:
|
||
|
|
|
||
|
|
```svelte
|
||
|
|
{#if contextMenu}
|
||
|
|
<TimelineContextMenu
|
||
|
|
x={contextMenu.x}
|
||
|
|
y={contextMenu.y}
|
||
|
|
time={contextMenu.time}
|
||
|
|
selectedClipId={clipStore.selectedClipId}
|
||
|
|
onAction={handleContextMenuAction}
|
||
|
|
onClose={() => { contextMenu = null; }}
|
||
|
|
/>
|
||
|
|
{/if}
|
||
|
|
```
|
||
|
|
|
||
|
|
Note: the `TimelineContextMenu` uses `position: fixed`, so it's positioned relative to the viewport regardless of where in the DOM it's rendered. Placing it inside `Timeline.svelte` is fine.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Close context menu on scroll/wheel**
|
||
|
|
|
||
|
|
Update the existing `handleWheel` in `Timeline.svelte` to close the menu:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
function handleWheel(e: WheelEvent) {
|
||
|
|
e.preventDefault();
|
||
|
|
contextMenu = null; // Close context menu on scroll
|
||
|
|
|
||
|
|
// ... rest of existing logic
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Also close on mousedown (clicking the timeline to seek should dismiss it):
|
||
|
|
|
||
|
|
At the start of `handleMouseDown`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
function handleMouseDown(e: MouseEvent) {
|
||
|
|
contextMenu = null;
|
||
|
|
// ... rest of existing logic
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Build and verify**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm run check
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: no errors.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm run tauri dev
|
||
|
|
```
|
||
|
|
|
||
|
|
Verify:
|
||
|
|
1. Right-clicking the timeline shows the custom context menu (not the default Reload/Inspect)
|
||
|
|
2. The playhead moves to the right-click position
|
||
|
|
3. The menu header shows the timestamp at the clicked position
|
||
|
|
4. "Mark In" sets an in-point at that time
|
||
|
|
5. "Mark Out" after "Mark In" creates a clip
|
||
|
|
6. "Go to Clip Start" / "Go to Clip End" are disabled when no clip is selected
|
||
|
|
7. Selecting a clip then right-clicking enables those items
|
||
|
|
8. "Delete Clip" only appears when a clip is selected, and removes it
|
||
|
|
9. Clicking outside the menu closes it
|
||
|
|
10. Pressing Escape closes the menu
|
||
|
|
11. Scrolling/zooming the timeline closes the menu
|
||
|
|
12. The menu flips position if it would extend beyond the window edge
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A
|
||
|
|
git commit -m "feat: timeline right-click context menu with mark/seek/delete actions"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 5: Final Verification
|
||
|
|
|
||
|
|
**Files:** none (verification only)
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: all prior tasks
|
||
|
|
- Produces: confidence that v0.1.3 is shippable
|
||
|
|
|
||
|
|
- [ ] **Step 1: Clean build check**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd /Users/erikfredericks/dev-ai/one-offs/gui-video-clipper
|
||
|
|
npm run check
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: zero errors, zero warnings.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Full integration test**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm run tauri dev
|
||
|
|
```
|
||
|
|
|
||
|
|
Test the following end-to-end:
|
||
|
|
1. Load a video via URL
|
||
|
|
2. Toggle layout between bottom and left modes
|
||
|
|
3. In left mode: resize the sidebar, verify video + transport fill remaining space
|
||
|
|
4. In left mode: timeline is full width below
|
||
|
|
5. Switch back to bottom mode — clip list returns below timeline
|
||
|
|
6. Move mouse across timeline — timestamps magnify near cursor
|
||
|
|
7. Right-click timeline — context menu appears, Mark In works
|
||
|
|
8. Right-click again further along — Mark Out creates a clip
|
||
|
|
9. Select clip, right-click — Go to Clip Start/End and Delete Clip work
|
||
|
|
10. Verify keyboard shortcuts (I, O, Space, etc.) still work
|
||
|
|
11. Verify version shows as 0.1.3 in status bar / about dialog
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit any fixups if needed**
|
||
|
|
|
||
|
|
If any issues were found and fixed, commit them:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A
|
||
|
|
git commit -m "fix: v0.1.3 polish fixups"
|
||
|
|
```
|