Compare commits
11 Commits
9499d776fd
...
c90b6765b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
c90b6765b7
|
|||
|
23b30be357
|
|||
|
124542b4c4
|
|||
|
279e27a9b5
|
|||
|
8c271773bd
|
|||
|
27c199e090
|
|||
|
923c7b0e23
|
|||
|
b371ec6c3e
|
|||
|
0b8bec2496
|
|||
|
91550dacd6
|
|||
|
9752a6d614
|
@@ -0,0 +1,56 @@
|
||||
# v0.1.3 Polish/Tweaks Implementation Summary
|
||||
|
||||
**Date:** 2026-09-22 16:55 ET
|
||||
**Version:** 0.1.2 → 0.1.3
|
||||
|
||||
## Task Description
|
||||
|
||||
Implemented three UI polish features for v0.1.3 of the GUI Video Clipper (Tauri + Svelte 5):
|
||||
|
||||
1. **Adjustable clip list layout** — clip list can appear below the timeline (default) or as a left sidebar next to the video preview, with a toolbar toggle button and persisted preference
|
||||
2. **Timeline timestamp magnification** — increased base font from 10px to 12px, with a mouse-proximity magnification effect using cosine interpolation (~18px peak, 80px radius)
|
||||
3. **Timeline right-click context menu** — custom context menu replacing the default web menu, with Mark In/Out, Go to Clip Start/End, and Delete Clip actions
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Commits (oldest → newest)
|
||||
- `0b8bec2` — chore: bump version to 0.1.3
|
||||
- `b371ec6` — feat: adjustable clip list layout (bottom/left sidebar)
|
||||
- `923c7b0` — fix: clip list width edge cases (falsy check, narrow-window clamp)
|
||||
- `27c199e` — feat: larger timeline timestamps with mouse-proximity magnification
|
||||
- `8c27177` — feat: timeline right-click context menu with mark/seek/delete actions
|
||||
- `279e27a` — docs: user edits to v0.1.3 spec (version bump section)
|
||||
|
||||
### Files Modified
|
||||
- `VERSION` — new file, version 0.1.3
|
||||
- `package.json` — version bump
|
||||
- `package-lock.json` — lockfile sync
|
||||
- `src-tauri/tauri.conf.json` — version bump
|
||||
- `src-tauri/Cargo.toml` — version bump
|
||||
- `src-tauri/Cargo.lock` — lockfile sync
|
||||
- `src/lib/stores/preferences.svelte.ts` — added `clipListPosition` and `clipListWidth` preferences
|
||||
- `src/App.svelte` — dual layout modes (bottom/left), toggle button, sidebar resize
|
||||
- `src/lib/components/ClipList.svelte` — added `position` prop for sidebar styling
|
||||
- `src/lib/timeline/renderer.ts` — larger base font, magnification math in `drawTimeTicks()`
|
||||
- `src/lib/components/Timeline.svelte` — mouseX tracking, context menu wiring
|
||||
- `src/lib/components/TimelineContextMenu.svelte` — new context menu component
|
||||
|
||||
### Design & Plan Docs
|
||||
- `docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md` — design spec
|
||||
- `docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md` — implementation plan
|
||||
|
||||
## Process
|
||||
|
||||
Used subagent-driven development: 5 tasks dispatched as subagents with task reviews between each. Two reviewer findings on Task 2 (falsy width check, narrow-window clamp) were fixed before proceeding.
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
- The layout restructure required careful conditional rendering in App.svelte — the `{#if}/{:else}` approach creates two separate DOM trees. This means TransportControls gets remounted on layout toggle, which should be smoke-tested.
|
||||
- Context menu `$effect` for viewport clamping can flash position (0,0) for one frame before adjustment — initializing adjusted positions from the raw x/y props would eliminate this.
|
||||
- The bump-version.sh script doesn't sync package-lock.json — needed manual `npm install --package-lock-only`.
|
||||
|
||||
## Follow-Up Items
|
||||
|
||||
- [ ] Manual smoke test with `npm run tauri dev` — layout toggle, sidebar resize, timestamp magnification feel, context menu flow
|
||||
- [ ] Consider fixing first-frame context menu position flash
|
||||
- [ ] Consider adding `onresize` clamp for sidebar width when window shrinks below saved width
|
||||
@@ -0,0 +1,44 @@
|
||||
# Fix Resize + Waveform Alignment
|
||||
|
||||
**Date:** 2026-09-22 17:42
|
||||
**Task:** Implement the "Fix Resize + Waveform Alignment" plan to fix horizontal resize regression and waveform/lane alignment issues.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Resize Regression Fix — `src/App.svelte`
|
||||
- **Root cause:** After the CSS-grid refactor, `handleResizeMove` used `splitAreaEl.clientHeight` as the upper bound. Since `.lower-section` only contained the resize handle (5px) + timeline pane, `totalHeight ≈ timelineHeight + 5`, so the clamp `Math.min(totalHeight - 20, ...)` immediately locked the height.
|
||||
- **Fix:**
|
||||
- Removed `splitAreaEl` state variable.
|
||||
- Removed `bind:this={splitAreaEl}` from the `.lower-section` div.
|
||||
- Simplified `handleResizeMove` to use a fixed max of 500 (matching the existing keyboard resize handler): `Math.max(40, Math.min(500, resizeStartHeight + delta))`.
|
||||
|
||||
### 2. Dynamic Lane Layout — `src/lib/timeline/renderer.ts`
|
||||
- Removed fixed lane height constants (`THUMB_LANE_HEIGHT`, `WAVEFORM_LANE_HEIGHT`, `CONTROLS_LANE_HEIGHT`).
|
||||
- Added `LaneLayout` interface and `computeLanes(totalHeight)` function that dynamically computes lane positions proportionally based on total canvas height.
|
||||
- Controls lane: fixed 30px at bottom.
|
||||
- Clip lane: fixed 30px above controls.
|
||||
- Remaining space split 55/45 between thumbnails and waveform (with minimums of 20px and 15px).
|
||||
- Updated `drawTimeline` to call `computeLanes(height)` and pass layout to all sub-draw functions.
|
||||
- Updated `drawTimeTicks` to accept `LaneLayout` and use `lanes.controlsY` instead of `state.height - CONTROLS_LANE_HEIGHT`.
|
||||
|
||||
### 3. Clip Renderer Update — `src/lib/timeline/clipRenderer.ts`
|
||||
- Removed hardcoded `CLIP_LANE_Y` and `CLIP_LANE_HEIGHT` constants.
|
||||
- Updated `drawClips` and `hitTestClip` signatures to accept `LaneLayout` parameter.
|
||||
- All clip positioning now uses `lanes.clipY` and `lanes.clipHeight`.
|
||||
|
||||
### 4. Timeline Component Update — `src/lib/components/Timeline.svelte`
|
||||
- Imported `computeLanes` from renderer.
|
||||
- Updated `hitTestClip` call to pass `computeLanes(timelineState.height)`.
|
||||
|
||||
## Verification
|
||||
- `npm run check` (svelte-check): 0 errors, 0 warnings.
|
||||
|
||||
## Files Modified
|
||||
- `src/App.svelte`
|
||||
- `src/lib/timeline/renderer.ts`
|
||||
- `src/lib/timeline/clipRenderer.ts`
|
||||
- `src/lib/components/Timeline.svelte`
|
||||
|
||||
## Lessons Learned
|
||||
- When using CSS grid layouts, elements that previously represented the "total space" may now only contain their own children. Relying on `clientHeight` of such elements for resize calculations breaks — use fixed bounds instead.
|
||||
- Dynamic lane computation is preferable to fixed pixel constants because it adapts to any canvas height, preventing gaps or overlaps when the user resizes the timeline.
|
||||
1060
docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md
Normal file
1060
docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md
Normal file
File diff suppressed because it is too large
Load Diff
201
docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md
Normal file
201
docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# v0.1.3 Polish & Tweaks — Design Spec
|
||||
|
||||
**Date:** 2026-09-22
|
||||
**Version:** 0.1.2 → 0.1.3
|
||||
|
||||
## Overview
|
||||
|
||||
Three UI polish features for v0.1.3: adjustable clip-list layout, improved timeline timestamp labels with a mouse-proximity magnification effect, and a custom right-click context menu on the timeline.
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Adjustable Layout (Clip List Position)
|
||||
|
||||
### Current State
|
||||
|
||||
The main content area is a vertical stack:
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Toolbar │
|
||||
├──────────────────────────────┤
|
||||
│ Video Preview │
|
||||
├──────────────────────────────┤
|
||||
│ Transport Controls │
|
||||
├──────────────────────────────┤
|
||||
│ Timeline / Thumbs / Waveform │
|
||||
├──────────────────────────────┤
|
||||
│ Clip List │
|
||||
├──────────────────────────────┤
|
||||
│ Status Bar │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Proposed: Two Layout Modes
|
||||
|
||||
A new `clipListPosition` preference (`'bottom' | 'left'`) controls where the clip list appears. Default: `'bottom'` (current behavior, unchanged).
|
||||
|
||||
When set to `'left'`, the layout becomes:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Toolbar │
|
||||
├──────────┬───────────────────────────────┤
|
||||
│ Clip │ Video Preview │
|
||||
│ List │ │
|
||||
│ │ │
|
||||
│ ├───────────────────────────────┤
|
||||
│ │ Transport Controls │
|
||||
├──────────┴───────────────────────────────┤
|
||||
│ Timeline / Thumbs / Waveform (full) │
|
||||
├──────────────────────────────────────────┤
|
||||
│ Status Bar │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Key details:
|
||||
|
||||
- The clip list shares horizontal space only with the video preview and transport controls. The timeline remains full-width underneath — no horizontal space is sacrificed for timeline real estate.
|
||||
- A vertical drag-to-resize handle sits between the clip list and the video area, allowing the user to adjust the sidebar width. Default width: ~220px. Min: 150px. Max: 40% of window width.
|
||||
- The existing vertical resize handle between timeline and clip list only appears in `'bottom'` mode. In `'left'` mode, the timeline height is controlled by the existing `timelineHeight` state (still resizable via a horizontal drag handle between the upper section and the timeline).
|
||||
|
||||
### Toggle Button
|
||||
|
||||
A button in the toolbar (alongside ⚙ and ℹ) toggles between `'bottom'` and `'left'` modes. The icon visually hints at the current layout — e.g. a stacked-rows icon for bottom mode, a sidebar icon for left mode. Clicking it switches immediately and persists the preference.
|
||||
|
||||
### Persistence
|
||||
|
||||
- Add `clipListPosition: 'bottom' | 'left'` to the `Preferences` interface in `preferences.svelte.ts`.
|
||||
- Add `clipListWidth: number` (default 220) for the sidebar width in left mode.
|
||||
- Both are saved/loaded via the existing Tauri store mechanism.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- `App.svelte` is the only component that needs structural changes. `ClipList.svelte`, `VideoPlayer.svelte`, and `Timeline.svelte` remain unchanged — they just get placed in different containers.
|
||||
- In `'left'` mode, the `.content` area becomes:
|
||||
- A flex column with two children:
|
||||
1. **Upper section** (flex row): `[ClipList sidebar | flex column of [VideoPlayer, TransportControls]]`
|
||||
2. **Lower section** (same as before): timeline pane with resize handle
|
||||
- The vertical resize handle between the upper and lower sections works the same as the existing one.
|
||||
- In `'bottom'` mode: the current layout is preserved exactly.
|
||||
- The `ClipList` component needs no changes; it already fills its container. The only adjustment is removing the `border-top` when in left mode (the sidebar should have a `border-right` instead), which can be handled via a CSS class or prop.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Timeline Timestamp Labels
|
||||
|
||||
### Current State
|
||||
|
||||
Timestamp labels are rendered at `10px` font size in `drawTimeTicks()` in `renderer.ts`. They are small and hard to read.
|
||||
|
||||
### Proposed Changes
|
||||
|
||||
1. **Increase base font size** from `10px` to `12px`.
|
||||
2. **Mouse-proximity magnification effect:** As the cursor moves along the timeline, labels near the cursor smoothly scale up. This creates an invisible "magnifying lens" effect for timestamps.
|
||||
|
||||
### Magnification Behavior
|
||||
|
||||
- **Radius:** ~80px from cursor (in canvas pixel space).
|
||||
- **Peak size:** ~18px directly under the cursor.
|
||||
- **Falloff:** Smooth cosine interpolation from peak to base size. A label 80px away renders at base size; a label directly under the cursor renders at peak size.
|
||||
- **Vertical alignment:** Labels stay bottom-aligned as they grow — the text baseline remains constant, so larger labels grow upward. This prevents layout jitter.
|
||||
- **Cursor leaves timeline:** When the mouse leaves the canvas (`onmouseleave`), `mouseX` is set to `null` and all labels render at the base size. No magnification in this state.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- `Timeline.svelte` already handles `onmousemove`. Add tracking of the mouse X coordinate (a simple `let mouseX = $state<number | null>(null)`), updated on `mousemove` and cleared on `mouseleave`.
|
||||
- Pass `mouseX` into `drawTimeline()` → `drawTimeTicks()`.
|
||||
- `drawTimeTicks()` computes the per-label font size based on distance from `mouseX`:
|
||||
```
|
||||
distance = abs(labelX - mouseX)
|
||||
if distance > MAGNIFY_RADIUS: scale = 0
|
||||
else: scale = (1 + cos(PI * distance / MAGNIFY_RADIUS)) / 2
|
||||
fontSize = BASE_FONT_SIZE + BONUS_FONT_SIZE * scale
|
||||
```
|
||||
- Trigger a redraw whenever `mouseX` changes. Since `requestDraw()` already coalesces via `requestAnimationFrame`, this adds no extra frame overhead — it simply piggybacks on the next scheduled frame.
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Timeline Right-Click Context Menu
|
||||
|
||||
### Current State
|
||||
|
||||
Right-clicking the timeline shows the default web/Tauri context menu (Reload, Inspect Element), which is not useful.
|
||||
|
||||
### Proposed: Custom Context Menu
|
||||
|
||||
A styled context menu appears on right-click anywhere on the timeline canvas. Right-clicking also moves the playhead to the clicked position.
|
||||
|
||||
### Menu Items
|
||||
|
||||
| # | Label | Behavior | Availability |
|
||||
|---|-------|----------|-------------|
|
||||
| 1 | **Mark In** | Sets in-point at the right-clicked time (calls `markInPoint`) | Always |
|
||||
| 2 | **Mark Out** | Sets out-point at the right-clicked time (calls `markOutPoint`) | Always |
|
||||
| — | *(separator)* | | |
|
||||
| 3 | **Go to Clip Start** | Seeks playhead to `selectedClip.startTime` | Disabled if no clip selected |
|
||||
| 4 | **Go to Clip End** | Seeks playhead to `selectedClip.endTime` | Disabled if no clip selected |
|
||||
| — | *(separator)* | | |
|
||||
| 5 | **Delete Clip** | Removes the selected clip | Hidden if no clip selected |
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Right-click on canvas:** `contextmenu` event is intercepted, default is prevented. The playhead moves to the clicked time position. The context menu appears at the cursor's screen coordinates.
|
||||
- **Selecting an item:** Executes the action and closes the menu.
|
||||
- **Closing without action:** Click anywhere outside the menu, press Escape, or scroll — the menu closes.
|
||||
- **Positioning:** The menu is rendered as a positioned `<div>` appended at the `App.svelte` level (or uses a portal pattern) so it isn't clipped by any `overflow: hidden` containers. If the menu would extend beyond the window edge, it flips direction (e.g. opens leftward or upward).
|
||||
|
||||
### Component: `TimelineContextMenu.svelte`
|
||||
|
||||
Props:
|
||||
- `x: number` — screen X position
|
||||
- `y: number` — screen Y position
|
||||
- `time: number` — the timeline time at the right-click position
|
||||
- `selectedClipId: string | null` — to determine which items are enabled/hidden
|
||||
- `onAction: (action: ContextMenuAction) => void` — callback for menu item selection
|
||||
- `onClose: () => void` — callback to close the menu
|
||||
|
||||
Actions (discriminated union):
|
||||
```typescript
|
||||
type ContextMenuAction =
|
||||
| { type: 'mark-in' }
|
||||
| { type: 'mark-out' }
|
||||
| { type: 'go-to-clip-start' }
|
||||
| { type: 'go-to-clip-end' }
|
||||
| { type: 'delete-clip' };
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
- Dark theme matching the app's existing style (dark background, light text, hover highlights).
|
||||
- Subtle border/shadow to float above the timeline.
|
||||
- Disabled items: dimmed text, no hover effect, not clickable.
|
||||
- Separators: thin horizontal lines using `var(--border)`.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- `Timeline.svelte` adds an `oncontextmenu` handler on the canvas element.
|
||||
- The handler computes the time from the click X coordinate (using existing `computeClickTime`), seeks the playhead, and emits an event / sets state to show the context menu.
|
||||
- The context menu component is rendered conditionally in `Timeline.svelte` (or `App.svelte` if clipping is an issue).
|
||||
- Keyboard shortcut hints can optionally be shown in the menu items (e.g. "Mark In I") for discoverability.
|
||||
|
||||
---
|
||||
|
||||
## Version Bump
|
||||
|
||||
Execute `scripts/bump-version.sh` after incrementing `VERSION`.
|
||||
|
||||
Confirm all locations updated from `0.1.2` → `0.1.3`:
|
||||
- `package.json` — `version` field
|
||||
- `src-tauri/tauri.conf.json` — `version` field
|
||||
- `src-tauri/Cargo.toml` — `version` field
|
||||
- `grep`/`find` for remaining `0.1.2` references, fix any, and add to `scripts/bump-version.sh`
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- No changes to the backend (Rust). All three features are purely frontend.
|
||||
- No changes to clip store logic — existing `markInPoint`, `markOutPoint`, `removeClip`, `selectClip` functions are reused as-is.
|
||||
- No new dependencies.
|
||||
- The context menu does NOT appear on the minimap — only on the main timeline canvas.
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gui-video-clipper",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gui-video-clipper",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gui-video-clipper",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "macOS GUI video clipper (Tauri + Svelte)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -1281,7 +1281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gui-video-clipper"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"dirs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gui-video-clipper"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
description = "A macOS GUI app for clipping online videos"
|
||||
authors = ["cottongin"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "GUI Video Clipper",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"identifier": "xyz.cottongin.gui-video-clipper",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -13,8 +13,8 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "GUI Video Clipper",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"width": 1100,
|
||||
"height": 750
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
196
src/App.svelte
196
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';
|
||||
@@ -30,7 +30,13 @@
|
||||
let isResizing = $state(false);
|
||||
let resizeStartY = $state(0);
|
||||
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);
|
||||
|
||||
let isLeft = $derived(preferences.clipListPosition === 'left');
|
||||
|
||||
$effect(() => {
|
||||
loadPreferences();
|
||||
@@ -63,10 +69,9 @@
|
||||
}
|
||||
|
||||
function handleResizeMove(e: MouseEvent) {
|
||||
if (!isResizing || !splitAreaEl) return;
|
||||
if (!isResizing) return;
|
||||
const delta = e.clientY - resizeStartY;
|
||||
const totalHeight = splitAreaEl.clientHeight;
|
||||
const newHeight = Math.max(40, Math.min(totalHeight - 20, resizeStartHeight + delta));
|
||||
const newHeight = Math.max(40, Math.min(500, resizeStartHeight + delta));
|
||||
timelineHeight = newHeight;
|
||||
}
|
||||
|
||||
@@ -74,6 +79,34 @@
|
||||
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 minWidth = Math.min(150, maxWidth);
|
||||
const newWidth = Math.max(minWidth, 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 +189,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,23 +225,67 @@
|
||||
<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 />
|
||||
</div>
|
||||
<main class="content" class:layout-left={isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
|
||||
<!-- Sidebar clip list: visible only in left mode -->
|
||||
<div class="sidebar-area" class:hidden={!isLeft}>
|
||||
<ClipList onExport={() => (showExportDialog = true)} position="left" />
|
||||
</div>
|
||||
<div
|
||||
class="sidebar-handle"
|
||||
class:hidden={!isLeft}
|
||||
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) => {
|
||||
const maxWidth = Math.round(windowWidth * 0.4);
|
||||
const minWidth = Math.min(150, maxWidth);
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setClipListWidth(
|
||||
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
|
||||
);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
setClipListWidth(
|
||||
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
|
||||
);
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
|
||||
<!-- Video + Transport: always rendered, never destroyed -->
|
||||
<div class="video-area">
|
||||
<VideoPlayer />
|
||||
<TransportControls bind:this={transportControls} />
|
||||
</div>
|
||||
|
||||
<!-- Timeline section: always full width -->
|
||||
<div class="lower-section">
|
||||
<div
|
||||
class="resize-handle"
|
||||
class:active={isResizing}
|
||||
role="slider"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize timeline and clip list"
|
||||
aria-label="Resize timeline"
|
||||
aria-valuenow={timelineHeight}
|
||||
aria-valuemin={40}
|
||||
aria-valuemax={500}
|
||||
@@ -215,10 +301,15 @@
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
<div class="cliplist-pane">
|
||||
<ClipList onExport={() => (showExportDialog = true)} />
|
||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||||
<Timeline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom clip list: visible only in bottom mode -->
|
||||
<div class="bottom-cliplist" class:hidden={isLeft}>
|
||||
<ClipList onExport={() => (showExportDialog = true)} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
||||
@@ -240,20 +331,78 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- Grid layout: bottom mode (default) --- */
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"video"
|
||||
"lower"
|
||||
"bottom-clips";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-clip-area {
|
||||
.video-area {
|
||||
grid-area: video;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lower-section {
|
||||
grid-area: lower;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.bottom-cliplist {
|
||||
grid-area: bottom-clips;
|
||||
min-height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar elements hidden in bottom mode */
|
||||
.sidebar-area {
|
||||
grid-area: sidebar;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.sidebar-handle {
|
||||
grid-area: handle;
|
||||
width: 5px;
|
||||
background: var(--border);
|
||||
cursor: ew-resize;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-handle:hover,
|
||||
.sidebar-handle.active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* --- Grid layout: left mode --- */
|
||||
.content.layout-left {
|
||||
grid-template-areas:
|
||||
"sidebar handle video"
|
||||
"lower lower lower"
|
||||
"bottom-clips bottom-clips bottom-clips";
|
||||
grid-template-columns: var(--sidebar-width, 220px) 5px 1fr;
|
||||
grid-template-rows: 1fr auto 0;
|
||||
}
|
||||
|
||||
.timeline-pane {
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
@@ -273,10 +422,9 @@
|
||||
background: var(--accent, #89b4fa);
|
||||
}
|
||||
|
||||
.cliplist-pane {
|
||||
flex: 1;
|
||||
min-height: 20px;
|
||||
overflow: hidden;
|
||||
.layout-btn {
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.about-btn {
|
||||
|
||||
@@ -4,22 +4,26 @@
|
||||
selectClip,
|
||||
removeClip,
|
||||
updateClip,
|
||||
type Clip,
|
||||
} from '$lib/stores/clips.svelte';
|
||||
import { formatTime, parseTime } from '$lib/utils/time';
|
||||
import { session } from '$lib/stores/videoSession.svelte';
|
||||
import { seekTo } from '$lib/transport/playback';
|
||||
|
||||
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);
|
||||
let isSidebar = $derived(position === 'left');
|
||||
|
||||
function handleSelect(id: string) {
|
||||
selectClip(id);
|
||||
const clip = clips.find((c) => c.id === id);
|
||||
if (clip) {
|
||||
session.currentTime = clip.startTime;
|
||||
const videoEl = document.querySelector('video');
|
||||
if (videoEl) videoEl.currentTime = clip.startTime;
|
||||
seekTo(clip.startTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +40,29 @@
|
||||
|
||||
function handleContainerClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
// Only deselect if the click landed on the container itself, not a child
|
||||
if (target.classList.contains('clip-list')) {
|
||||
selectClip(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlayClip(clip: Clip) {
|
||||
seekTo(clip.startTime);
|
||||
const videoEl = document.querySelector('video');
|
||||
if (videoEl?.paused) videoEl.play();
|
||||
}
|
||||
|
||||
function handleGoToStart(clip: Clip) {
|
||||
seekTo(clip.startTime);
|
||||
}
|
||||
|
||||
function handleGoToEnd(clip: Clip) {
|
||||
seekTo(clip.endTime);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="clip-list"
|
||||
class:sidebar={isSidebar}
|
||||
role="listbox"
|
||||
tabindex="0"
|
||||
onclick={handleContainerClick}
|
||||
@@ -64,6 +82,7 @@
|
||||
<div
|
||||
class="clip-row"
|
||||
class:selected={clip.id === selectedId}
|
||||
class:sidebar={isSidebar}
|
||||
role="option"
|
||||
aria-selected={clip.id === selectedId}
|
||||
tabindex="0"
|
||||
@@ -75,42 +94,124 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="color-swatch" style="background: {clip.color}"></span>
|
||||
<input
|
||||
class="label-input"
|
||||
type="text"
|
||||
value={clip.label}
|
||||
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.startTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="arrow">→</span>
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.endTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeClip(clip.id);
|
||||
}}
|
||||
title="Delete clip"
|
||||
>✕</button>
|
||||
{#if isSidebar}
|
||||
<!-- Sidebar (vertical card) layout -->
|
||||
<div class="clip-card">
|
||||
<div class="card-row">
|
||||
<span class="color-swatch" style="background: {clip.color}"></span>
|
||||
<input
|
||||
class="label-input sidebar-label"
|
||||
type="text"
|
||||
value={clip.label}
|
||||
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div class="card-row times">
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.startTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="arrow">→</span>
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.endTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
{#if clip.id === selectedId}
|
||||
<div class="clip-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
|
||||
title="Play clip"
|
||||
>▶</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handleGoToStart(clip); }}
|
||||
title="Go to start"
|
||||
>|◀</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handleGoToEnd(clip); }}
|
||||
title="Go to end"
|
||||
>▶|</button>
|
||||
<button
|
||||
class="action-btn danger"
|
||||
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
|
||||
title="Delete clip"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Bottom (wide row) layout -->
|
||||
<span class="color-swatch" style="background: {clip.color}"></span>
|
||||
<input
|
||||
class="label-input"
|
||||
type="text"
|
||||
value={clip.label}
|
||||
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.startTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="arrow">→</span>
|
||||
<input
|
||||
class="time-input"
|
||||
type="text"
|
||||
value={formatTime(clip.endTime)}
|
||||
onchange={(e) =>
|
||||
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{#if clip.id === selectedId}
|
||||
<div class="clip-actions inline">
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
|
||||
title="Play clip"
|
||||
>▶</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handleGoToStart(clip); }}
|
||||
title="Go to start"
|
||||
>|◀</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => { e.stopPropagation(); handleGoToEnd(clip); }}
|
||||
title="Go to end"
|
||||
>▶|</button>
|
||||
<button
|
||||
class="action-btn danger"
|
||||
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
|
||||
title="Delete clip"
|
||||
>✕</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
|
||||
title="Delete clip"
|
||||
>✕</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="action-buttons">
|
||||
<div class="list-actions">
|
||||
{#if selectedId}
|
||||
<button type="button" class="deselect-btn" onclick={() => selectClip(null)}>Deselect</button>
|
||||
{/if}
|
||||
@@ -132,6 +233,12 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clip-list.sidebar {
|
||||
border-top: none;
|
||||
height: 100%;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
@@ -146,7 +253,7 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
.list-actions {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -154,7 +261,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
.list-actions button {
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
@@ -164,6 +271,7 @@
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* --- Bottom (wide) row layout --- */
|
||||
.clip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -186,6 +294,32 @@
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* --- Sidebar (vertical card) layout --- */
|
||||
.clip-row.sidebar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.clip-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.card-row.times {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --- Shared elements --- */
|
||||
.color-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
@@ -202,6 +336,12 @@
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.label-input.sidebar-label {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label-input:focus {
|
||||
background: var(--bg-primary);
|
||||
outline: 1px solid var(--accent);
|
||||
@@ -230,6 +370,44 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* --- Clip action buttons --- */
|
||||
.clip-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.clip-actions.inline {
|
||||
padding-top: 0;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: var(--danger);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* Legacy delete button for unselected clips in bottom mode */
|
||||
.delete-btn {
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<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 { drawTimeline, type TimelineState, computeLanes } from '$lib/timeline/renderer';
|
||||
import {
|
||||
computeZoom,
|
||||
panBy,
|
||||
@@ -36,6 +41,12 @@
|
||||
let isMinimapDragging = $state(false);
|
||||
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);
|
||||
|
||||
@@ -84,7 +95,8 @@
|
||||
clipStore.pendingInPoint,
|
||||
waveformData,
|
||||
session.thumbnailSpritesheets,
|
||||
session.status === 'ready'
|
||||
session.status === 'ready',
|
||||
mouseX
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -154,6 +166,7 @@
|
||||
void timelineState.width;
|
||||
void timelineState.height;
|
||||
void canvas;
|
||||
void mouseX;
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
@@ -232,11 +245,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
case 'play-clip': {
|
||||
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
|
||||
if (clip) {
|
||||
seekTo(clip.startTime);
|
||||
const videoEl = document.querySelector('video');
|
||||
if (videoEl?.paused) videoEl.play();
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = action;
|
||||
void _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
contextMenu = null;
|
||||
const x = getCanvasX(e);
|
||||
const y = getCanvasY(e);
|
||||
|
||||
const hit = hitTestClip(x, y, timelineState, clipStore.clips);
|
||||
const hit = hitTestClip(x, y, timelineState, clipStore.clips, computeLanes(timelineState.height));
|
||||
if (hit) {
|
||||
if (hit.edge === 'body') {
|
||||
selectClip(hit.clipId);
|
||||
@@ -284,8 +348,18 @@
|
||||
dragTarget = null;
|
||||
}
|
||||
|
||||
function handleMouseTrack(e: MouseEvent) {
|
||||
mouseX = getCanvasX(e);
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
handleMouseUp();
|
||||
mouseX = null;
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
contextMenu = null;
|
||||
|
||||
if (e.deltaX !== 0) {
|
||||
const result = panBy(timelineState, e.deltaX, session.duration);
|
||||
@@ -376,14 +450,31 @@
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
onmousedown={handleMouseDown}
|
||||
onmousemove={handleMouseMove}
|
||||
onmousemove={(e) => {
|
||||
handleMouseMove(e);
|
||||
handleMouseTrack(e);
|
||||
}}
|
||||
onmouseup={handleMouseUp}
|
||||
onmouseleave={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}
|
||||
|
||||
215
src/lib/components/TimelineContextMenu.svelte
Normal file
215
src/lib/components/TimelineContextMenu.svelte
Normal file
@@ -0,0 +1,215 @@
|
||||
<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: 'play-clip' }
|
||||
| { 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"
|
||||
role="menuitem"
|
||||
onclick={() => handleAction({ type: 'play-clip' })}
|
||||
>
|
||||
<span class="label">Play Clip</span>
|
||||
<span class="shortcut">▶</span>
|
||||
</button>
|
||||
<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>
|
||||
@@ -135,6 +135,10 @@
|
||||
|
||||
function handleLoadedData() {
|
||||
videoError = null;
|
||||
// Restore playhead position (e.g. after layout toggle remounts the video)
|
||||
if (videoElement && session.currentTime > 0) {
|
||||
videoElement.currentTime = session.currentTime;
|
||||
}
|
||||
if (videoElement) {
|
||||
console.log('[VideoPlayer] loadeddata:', {
|
||||
src: videoElement.src,
|
||||
|
||||
@@ -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 != null) 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();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Clip } from '$lib/stores/clips.svelte';
|
||||
import { timeToX, type TimelineState } from './renderer';
|
||||
import { timeToX, type TimelineState, type LaneLayout } from './renderer';
|
||||
|
||||
const CLIP_LANE_Y = 90;
|
||||
const CLIP_LANE_HEIGHT = 30;
|
||||
const HANDLE_WIDTH = 6;
|
||||
|
||||
export function drawClips(
|
||||
@@ -10,24 +8,28 @@ export function drawClips(
|
||||
state: TimelineState,
|
||||
clips: Clip[],
|
||||
selectedId: string | null,
|
||||
pendingInPoint: number | null
|
||||
pendingInPoint: number | null,
|
||||
lanes: LaneLayout
|
||||
): void {
|
||||
const clipY = lanes.clipY;
|
||||
const clipH = lanes.clipHeight;
|
||||
|
||||
for (const clip of clips) {
|
||||
const x1 = timeToX(clip.startTime, state);
|
||||
const x2 = timeToX(clip.endTime, state);
|
||||
const isSelected = clip.id === selectedId;
|
||||
|
||||
ctx.fillStyle = clip.color + '66';
|
||||
ctx.fillRect(x1, CLIP_LANE_Y, x2 - x1, CLIP_LANE_HEIGHT);
|
||||
ctx.fillRect(x1, clipY, x2 - x1, clipH);
|
||||
|
||||
ctx.strokeStyle = isSelected ? '#ffffff' : clip.color;
|
||||
ctx.lineWidth = isSelected ? 2 : 1;
|
||||
ctx.strokeRect(x1, CLIP_LANE_Y, x2 - x1, CLIP_LANE_HEIGHT);
|
||||
ctx.strokeRect(x1, clipY, x2 - x1, clipH);
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.fillStyle = isSelected ? '#ffffff' : clip.color;
|
||||
ctx.fillRect(x1 - HANDLE_WIDTH / 2, CLIP_LANE_Y, HANDLE_WIDTH, CLIP_LANE_HEIGHT);
|
||||
ctx.fillRect(x2 - HANDLE_WIDTH / 2, CLIP_LANE_Y, HANDLE_WIDTH, CLIP_LANE_HEIGHT);
|
||||
ctx.fillRect(x1 - HANDLE_WIDTH / 2, clipY, HANDLE_WIDTH, clipH);
|
||||
ctx.fillRect(x2 - HANDLE_WIDTH / 2, clipY, HANDLE_WIDTH, clipH);
|
||||
|
||||
const labelWidth = x2 - x1;
|
||||
if (labelWidth > 40) {
|
||||
@@ -37,7 +39,7 @@ export function drawClips(
|
||||
ctx.fillText(
|
||||
clip.label,
|
||||
(x1 + x2) / 2,
|
||||
CLIP_LANE_Y + CLIP_LANE_HEIGHT / 2 + 4,
|
||||
clipY + clipH / 2 + 4,
|
||||
labelWidth - 10
|
||||
);
|
||||
}
|
||||
@@ -49,8 +51,8 @@ export function drawClips(
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, CLIP_LANE_Y);
|
||||
ctx.lineTo(x, CLIP_LANE_Y + CLIP_LANE_HEIGHT);
|
||||
ctx.moveTo(x, clipY);
|
||||
ctx.lineTo(x, clipY + clipH);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.lineWidth = 1;
|
||||
@@ -66,9 +68,10 @@ export function hitTestClip(
|
||||
x: number,
|
||||
y: number,
|
||||
state: TimelineState,
|
||||
clips: Clip[]
|
||||
clips: Clip[],
|
||||
lanes: LaneLayout
|
||||
): ClipHitResult | null {
|
||||
if (y < CLIP_LANE_Y || y > CLIP_LANE_Y + CLIP_LANE_HEIGHT) {
|
||||
if (y < lanes.clipY || y > lanes.clipY + lanes.clipHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,40 @@ export function xToTime(x: number, state: TimelineState): number {
|
||||
return state.visibleStart + (x / state.width) * range;
|
||||
}
|
||||
|
||||
const THUMB_LANE_HEIGHT = 50;
|
||||
const WAVEFORM_LANE_HEIGHT = 40;
|
||||
const CONTROLS_LANE_HEIGHT = 30;
|
||||
const TICK_HEIGHT = 10;
|
||||
const BASE_FONT_SIZE = 12;
|
||||
const MAGNIFY_BONUS = 10;
|
||||
const MAGNIFY_RADIUS = 100;
|
||||
|
||||
const CONTROLS_HEIGHT = 22;
|
||||
const CLIP_HEIGHT = 20;
|
||||
|
||||
export interface LaneLayout {
|
||||
thumbY: number;
|
||||
thumbHeight: number;
|
||||
waveformY: number;
|
||||
waveformHeight: number;
|
||||
clipY: number;
|
||||
clipHeight: number;
|
||||
controlsY: number;
|
||||
controlsHeight: number;
|
||||
}
|
||||
|
||||
export function computeLanes(totalHeight: number): LaneLayout {
|
||||
const available = Math.max(0, totalHeight - CONTROLS_HEIGHT - CLIP_HEIGHT);
|
||||
const thumbHeight = Math.max(20, Math.round(available * 0.55));
|
||||
const waveformHeight = Math.max(15, available - thumbHeight);
|
||||
return {
|
||||
thumbY: 0,
|
||||
thumbHeight,
|
||||
waveformY: thumbHeight,
|
||||
waveformHeight,
|
||||
clipY: thumbHeight + waveformHeight,
|
||||
clipHeight: CLIP_HEIGHT,
|
||||
controlsY: totalHeight - CONTROLS_HEIGHT,
|
||||
controlsHeight: CONTROLS_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
export function drawTimeline(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
@@ -38,7 +68,8 @@ export function drawTimeline(
|
||||
pendingInPoint: number | null = null,
|
||||
waveform: WaveformData = { tiers: null },
|
||||
thumbnailSpritesheets: ThumbnailSpritesheet[] = [],
|
||||
hasVideo: boolean = false
|
||||
hasVideo: boolean = false,
|
||||
mouseX: number | null = null
|
||||
): void {
|
||||
const { width, height } = state;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
@@ -46,21 +77,23 @@ export function drawTimeline(
|
||||
ctx.fillStyle = '#181825';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
drawTimeTicks(ctx, state, duration);
|
||||
const lanes = computeLanes(height);
|
||||
|
||||
drawTimeTicks(ctx, state, duration, mouseX, lanes);
|
||||
|
||||
if (thumbnailSpritesheets.length > 0) {
|
||||
drawThumbnails(ctx, state, thumbnailSpritesheets, duration, 0, THUMB_LANE_HEIGHT);
|
||||
drawThumbnails(ctx, state, thumbnailSpritesheets, duration, lanes.thumbY, lanes.thumbHeight);
|
||||
} else {
|
||||
drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails', hasVideo);
|
||||
drawPlaceholderLane(ctx, state, lanes.thumbY, lanes.thumbHeight, 'Thumbnails', hasVideo);
|
||||
}
|
||||
|
||||
if (waveform.tiers) {
|
||||
drawWaveform(ctx, state, waveform, duration, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT);
|
||||
drawWaveform(ctx, state, waveform, duration, lanes.waveformY, lanes.waveformHeight);
|
||||
} else {
|
||||
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform', hasVideo);
|
||||
drawPlaceholderLane(ctx, state, lanes.waveformY, lanes.waveformHeight, 'Waveform', hasVideo);
|
||||
}
|
||||
|
||||
drawClips(ctx, state, clips, selectedClipId, pendingInPoint);
|
||||
drawClips(ctx, state, clips, selectedClipId, pendingInPoint, lanes);
|
||||
|
||||
drawPlayhead(ctx, state, currentTime);
|
||||
}
|
||||
@@ -68,27 +101,66 @@ export function drawTimeline(
|
||||
function drawTimeTicks(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: TimelineState,
|
||||
duration: number
|
||||
duration: number,
|
||||
mouseX: number | null,
|
||||
lanes: LaneLayout
|
||||
): void {
|
||||
const range = state.visibleEnd - state.visibleStart;
|
||||
const tickInterval = getTickInterval(range, state.width);
|
||||
const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval;
|
||||
|
||||
ctx.fillStyle = '#6c7086';
|
||||
ctx.font = '10px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
const baselineY = lanes.controlsY + TICK_HEIGHT + 10;
|
||||
|
||||
// Draw minor ticks first (behind major ticks)
|
||||
const minorInterval = tickInterval / 4;
|
||||
const minorTickHeight = TICK_HEIGHT * 0.5;
|
||||
for (let t = startTick - tickInterval; t <= state.visibleEnd + minorInterval; t += minorInterval) {
|
||||
if (t < 0 || t > duration) continue;
|
||||
if (Math.abs(t % tickInterval) < 0.001 || Math.abs(t % tickInterval - tickInterval) < 0.001) continue;
|
||||
const x = timeToX(t, state);
|
||||
ctx.strokeStyle = '#35354a';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, lanes.controlsY);
|
||||
ctx.lineTo(x, lanes.controlsY + minorTickHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw major ticks with labels
|
||||
for (let t = startTick; t <= state.visibleEnd; t += tickInterval) {
|
||||
if (t < 0 || t > duration) continue;
|
||||
const x = timeToX(t, state);
|
||||
|
||||
ctx.strokeStyle = '#45475a';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, state.height - CONTROLS_LANE_HEIGHT);
|
||||
ctx.lineTo(x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT);
|
||||
ctx.moveTo(x, lanes.controlsY);
|
||||
ctx.lineTo(x, lanes.controlsY + TICK_HEIGHT);
|
||||
ctx.stroke();
|
||||
|
||||
let fontSize = BASE_FONT_SIZE;
|
||||
let scale = 0;
|
||||
if (mouseX !== null) {
|
||||
const distance = Math.abs(x - mouseX);
|
||||
if (distance < MAGNIFY_RADIUS) {
|
||||
scale = (1 + Math.cos((Math.PI * distance) / MAGNIFY_RADIUS)) / 2;
|
||||
fontSize = BASE_FONT_SIZE + MAGNIFY_BONUS * scale;
|
||||
}
|
||||
}
|
||||
|
||||
// Interpolate color from muted (#6c7086) to bright (#cdd6f4)
|
||||
if (scale > 0) {
|
||||
const r = Math.round(108 + (205 - 108) * scale);
|
||||
const g = Math.round(112 + (214 - 112) * scale);
|
||||
const b = Math.round(134 + (244 - 134) * scale);
|
||||
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
} else {
|
||||
ctx.fillStyle = '#6c7086';
|
||||
}
|
||||
|
||||
ctx.font = `${Math.round(fontSize)}px -apple-system, sans-serif`;
|
||||
|
||||
const label = formatTickLabel(t);
|
||||
ctx.fillText(label, x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT + 12);
|
||||
ctx.fillText(label, x, baselineY);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user