diff --git a/.changeset/add-phase-1-complete.md b/.changeset/add-phase-1-complete.md index 1b7c73c..b1ca2cb 100644 --- a/.changeset/add-phase-1-complete.md +++ b/.changeset/add-phase-1-complete.md @@ -1,7 +1,7 @@ --- -"@timeline/core": minor -"@timeline/react": minor -"@timeline/ui": minor +"@webpacked-timeline/core": minor +"@webpacked-timeline/react": minor +"@webpacked-timeline/ui": minor --- Phase 1 complete: headless NLE engine core with transaction-based dispatcher, snap index, ITool contract, ProvisionalState ghost layer, React hooks with selector isolation, and rAF-throttled tool router. diff --git a/.changeset/add-phase-2-tools.md b/.changeset/add-phase-2-tools.md index 591c38a..723293a 100644 --- a/.changeset/add-phase-2-tools.md +++ b/.changeset/add-phase-2-tools.md @@ -1,6 +1,6 @@ --- -"@timeline/core": minor -"@timeline/react": minor +"@webpacked-timeline/core": minor +"@webpacked-timeline/react": minor --- Phase 2 complete: 8 core editing tools (SelectionTool, RazorTool, RippleTrimTool, RollTrimTool, SlipTool, RippleDeleteTool, RippleInsertTool, HandTool). Rolling-state dispatcher validation. MOVE_CLIP ordering rule. ProvisionalState rubber-band extension. diff --git a/.changeset/config.json b/.changeset/config.json index b2d8d60..82f3935 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,7 +4,7 @@ "commit": false, "fixed": [], "linked": [ - ["@timeline/core", "@timeline/react", "@timeline/ui"] + ["@webpacked-timeline/core", "@webpacked-timeline/react", "@webpacked-timeline/ui"] ], "access": "public", "baseBranch": "main", diff --git a/.changeset/phase-3-complete.md b/.changeset/phase-3-complete.md index 9d9d09e..e7a46ac 100644 --- a/.changeset/phase-3-complete.md +++ b/.changeset/phase-3-complete.md @@ -1,7 +1,7 @@ --- -"@timeline/core": minor -"@timeline/react": minor -"@timeline/ui": minor +"@webpacked-timeline/core": minor +"@webpacked-timeline/react": minor +"@webpacked-timeline/ui": minor --- Phase 3 complete: Markers, in/out points, beat grid, generators, captions, SRT/VTT import. Marker search API. Caption model completeness (EDIT_CAPTION partial updates, overlap invariant). diff --git a/.changeset/phase-4-complete.md b/.changeset/phase-4-complete.md index 2a43135..ffe397b 100644 --- a/.changeset/phase-4-complete.md +++ b/.changeset/phase-4-complete.md @@ -1,7 +1,7 @@ --- -"@timeline/core": minor -"@timeline/react": minor -"@timeline/ui": minor +"@webpacked-timeline/core": minor +"@webpacked-timeline/react": minor +"@webpacked-timeline/ui": minor --- Phase 4: Effects, keyframes, transitions, track groups, link groups, and two new tools (TransitionTool, KeyframeTool). diff --git a/.changeset/phase-6-complete.md b/.changeset/phase-6-complete.md index 0c3385b..7b243e2 100644 --- a/.changeset/phase-6-complete.md +++ b/.changeset/phase-6-complete.md @@ -1,6 +1,6 @@ --- -"@timeline/core": minor -"@timeline/react": minor +"@webpacked-timeline/core": minor +"@webpacked-timeline/react": minor --- Phase 6: Playback engine — PlayheadController, pipeline contracts, seek API, J/K/L keyboard, loop regions, usePlayhead hook. diff --git a/.changeset/phase-7-complete.md b/.changeset/phase-7-complete.md index ac218c0..bf651ee 100644 --- a/.changeset/phase-7-complete.md +++ b/.changeset/phase-7-complete.md @@ -1,5 +1,5 @@ --- -"@timeline/core": minor +"@webpacked-timeline/core": minor --- Phase 7: Performance and scale — interval tree, virtual rendering, transaction compression, history persistence, worker contracts, SlideTool, ZoomTool. Feature complete. diff --git a/.changeset/phase-r-complete.md b/.changeset/phase-r-complete.md new file mode 100644 index 0000000..9e6d516 --- /dev/null +++ b/.changeset/phase-r-complete.md @@ -0,0 +1,6 @@ +--- +"@webpacked-timeline/react": minor +"@webpacked-timeline/core": patch +--- + +Phase R: @webpacked-timeline/react complete — TimelineEngine orchestrator, 13 hooks with selector isolation, ToolRouter with rAF throttle, virtual rendering hooks, full integration test suite. diff --git a/.changeset/phase-u-complete.md b/.changeset/phase-u-complete.md new file mode 100644 index 0000000..ff3c168 --- /dev/null +++ b/.changeset/phase-u-complete.md @@ -0,0 +1,5 @@ +--- +"@webpacked-timeline/ui": minor +--- + +Phase U: @webpacked-timeline/ui complete — shadcn-style CLI, 16 components across 5 tiers, 2 themes, shared utilities, full rendering contract. Feature complete. diff --git a/.claude/skills/ARCHITECTURE.md b/.claude/skills/ARCHITECTURE.md index 52eaf11..d7045c0 100644 --- a/.claude/skills/ARCHITECTURE.md +++ b/.claude/skills/ARCHITECTURE.md @@ -9,13 +9,13 @@ ``` packages/core → imports nothing outside core stdlib + TypeScript -packages/react → imports @timeline/core + React only -packages/ui → imports @timeline/react + @timeline/core + React only +packages/react → imports @webpacked-timeline/core + React only +packages/ui → imports @webpacked-timeline/react + @webpacked-timeline/core + React only ``` Lower layers NEVER import from higher layers. A `packages/core` file that imports `React`, `ReactDOM`, `requestAnimationFrame`, `document`, or anything from -`@timeline/react` or `@timeline/ui` is **categorically wrong** — reject it. +`@webpacked-timeline/react` or `@webpacked-timeline/ui` is **categorically wrong** — reject it. ## Rule 2 — One Entry Point for Mutation diff --git a/.claude/skills/adapter/HOOKS.md b/.claude/skills/adapter/HOOKS.md index 62f975c..e4293ac 100644 --- a/.claude/skills/adapter/HOOKS.md +++ b/.claude/skills/adapter/HOOKS.md @@ -54,13 +54,13 @@ function useClip(id: ClipId) { --- -## Hooks Never Import from @timeline/core Directly +## Hooks Never Import from @webpacked-timeline/core Directly All calls go through the `TimelineEngine` adapter class. Hooks never call `dispatch()` directly. ```typescript // ❌ -import { dispatch } from "@timeline/core"; +import { dispatch } from "@webpacked-timeline/core"; // ✅ const { engine } = useTimelineContext(); @@ -167,6 +167,6 @@ function ClipShell({ id }: { id: ClipId }) { ## Common mistakes to avoid -- Importing `dispatch` from `@timeline/core` inside a hook — always call `engine.dispatch()` +- Importing `dispatch` from `@webpacked-timeline/core` inside a hook — always call `engine.dispatch()` - Using `useEffect` + `useState` to mirror engine state — use `useSyncExternalStore` always - Subscribing to `engine.getState` (entire state snapshot) — always write a scoped selector diff --git a/.claude/skills/tools/ITOOL_CONTRACT.md b/.claude/skills/tools/ITOOL_CONTRACT.md index a276744..8e3fc33 100644 --- a/.claude/skills/tools/ITOOL_CONTRACT.md +++ b/.claude/skills/tools/ITOOL_CONTRACT.md @@ -154,7 +154,7 @@ import type { TimelinePointerEvent, TimelineKeyEvent, ProvisionalState, -} from "@timeline/core"; +} from "@webpacked-timeline/core"; class NoOpTool implements ITool { readonly id = "no-op" as ToolId; @@ -186,7 +186,7 @@ Use `NoOpTool` as a base for unit tests that need an `ITool` but don't care abou ```typescript import { describe, it, expect } from "vitest"; -import { dispatch } from "@timeline/core"; +import { dispatch } from "@webpacked-timeline/core"; it("MoveTool produces correct MOVE_CLIP transaction", () => { const tool = new MoveTool(); @@ -399,7 +399,7 @@ packages/react/src/tests/ ``` Unit tests live in core. Integration tests live in react. -A tool test that imports anything from `@timeline/react` is wrong. +A tool test that imports anything from `@webpacked-timeline/react` is wrong. --- diff --git a/.claude/skills/ui/COMPONENTS.md b/.claude/skills/ui/COMPONENTS.md index 4dcc95f..8384c3a 100644 --- a/.claude/skills/ui/COMPONENTS.md +++ b/.claude/skills/ui/COMPONENTS.md @@ -3,18 +3,18 @@ --- -# COMPONENTS — @timeline/ui Rules +# COMPONENTS — @webpacked-timeline/ui Rules ## Critical Rule -**No component imports from `@timeline/core` directly.** No component calls `dispatch()` directly. State flows in through hooks only. +**No component imports from `@webpacked-timeline/core` directly.** No component calls `dispatch()` directly. State flows in through hooks only. ```typescript // ❌ WRONG -import { dispatch, findClipById } from "@timeline/core"; +import { dispatch, findClipById } from "@webpacked-timeline/core"; // ✅ CORRECT -import { useClip, useTimeline } from "@timeline/react"; +import { useClip, useTimeline } from "@webpacked-timeline/react"; ``` --- @@ -102,7 +102,7 @@ function TrackRow({ id }: { id: TrackId }) { ## What Components Do NOT Do -- ❌ Import from `@timeline/core` directly +- ❌ Import from `@webpacked-timeline/core` directly - ❌ Call `dispatch()` directly - ❌ Implement drag logic (that belongs in `packages/react/tools/`) - ❌ Keep a `useState` copy of clip data diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8561e49..018d3a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,10 @@ jobs: - run: pnpm install --frozen-lockfile - name: Type check - run: pnpm --filter @timeline/core exec tsc --noEmit + run: pnpm --filter @webpacked-timeline/core exec tsc --noEmit - name: Test - run: pnpm --filter @timeline/core test --run + run: pnpm --filter @webpacked-timeline/core test --run - name: Build - run: pnpm --filter @timeline/core build \ No newline at end of file + run: pnpm --filter @webpacked-timeline/core build \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d695c4c..6278aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ _No unreleased changes._ ## [0.0.1] — Phase 0 Complete -### @timeline/core +### @webpacked-timeline/core #### Added - FrameRate discriminated union @@ -31,7 +31,7 @@ _No unreleased changes._ ## [0.1.0] — Phase 1 Complete -### @timeline/core +### @webpacked-timeline/core #### Added - Tool scaffolding: ITool interface, ToolContext, ToolRegistry @@ -44,7 +44,7 @@ _No unreleased changes._ ## [0.2.0] — Phase 2 Complete -### @timeline/core +### @webpacked-timeline/core #### Added - SelectionTool (click, drag, multi-drag, rubber-band) @@ -65,7 +65,7 @@ _No unreleased changes._ ## [0.3.0] — Phase 3 Complete — February 27, 2025 -### @timeline/core +### @webpacked-timeline/core #### Added @@ -140,6 +140,138 @@ _No unreleased changes._ --- +## [0.8.0] — Phase U Complete — February 27, 2025 + +### Added (@webpacked-timeline/ui) + +**CLI** +- `npx @webpacked-timeline/ui add ` — copies + components into your project +- `npx @webpacked-timeline/ui add --preset=` — + install curated bundles +- `npx @webpacked-timeline/ui list` — show all components + with install status +- `npx @webpacked-timeline/ui diff ` — + show changes vs registry +- `npx @webpacked-timeline/ui update ` — + update with diff preview + confirmation +- Theme install: `add theme --theme=dark-pro` +- Manifest tracking: `.timeline-ui.json` +- Registry dependency resolution (topological) + +**Theme system** +- `dark-pro` theme (DaVinci-inspired default) +- `light` theme (Final Cut Pro-inspired) +- 45+ CSS custom property tokens +- Token naming: `--tl-{component}-{property}-{state}` + +**Shared utilities** (copied into _shared/) +- `time.ts` — frameToPx, pxToFrame, + frameToTimecode, rulerTickInterval +- `geometry.ts` — Rect, clamp, normalizeRect, + rectsOverlap +- `use-drag.ts` — useDrag() hook with threshold +- `use-snap.ts` — useSnap() hook via engine + +**Components** + +Tier 1 — Core: + timeline-root, track, clip, playhead, ruler + +Tier 2 — Editing: + toolbar, zoom-bar + +Tier 3 — Media: + waveform (canvas), thumbnail-strip, clip-label + +Tier 4 — Advanced: + effect-lane, keyframe-diamond, + transition-handle + +Tier 5 — Markers: + marker-pin, marker-range, in-out-handles + +**Rendering contract** +- All components read engine via TimelineProvider +- Zero hardcoded colors (CSS vars only) +- Render prop escape hatches on all content + components +- No local useState for canonical engine values +- All mutations via engine.dispatch() only + +### Tests +- 113 UI tests (CLI + components) +- 1152+ total tests across monorepo +- Registry integrity: all registered files + verified to exist + +--- + +## [0.7.0] — Phase R Complete — February 27, 2025 + +### Added (@webpacked-timeline/react) + +**TimelineEngine** +- Full orchestrator wiring Dispatcher, + PlaybackEngine, ToolRouter, SnapIndexManager, + TrackIndex, HistoryStack, KeyboardHandler +- `TimelineEngineOptions`: pipeline, clock, + historyLimit, compression, tools, callbacks +- `EngineSnapshot`: state, provisional, playhead, + history, trackIds, cursor, change +- `DEFAULT_PLAYHEAD_STATE` for edit-only mode +- Playback events wired to snapshot rebuild + (usePlayheadFrame updates every rAF tick) +- `handlePointerLeave` with Option Y pattern +- `undo()` / `redo()` with full state sync + +**Hooks (13 total)** +- `useTimeline(engine)` — timeline metadata +- `useTrackIds(engine)` — stable track list +- `useTrack(engine, id)` — single track +- `useClip(engine, id)` — single clip, + provisional-first lookup +- `useClips(engine, trackId)` — track clips +- `useMarkers(engine)` — timeline markers +- `useHistory(engine)` — canUndo/canRedo, + stable object reference +- `useActiveToolId(engine)` — active tool +- `useCursor(engine)` — CSS cursor string +- `useProvisional(engine)` — ghost clip state +- `usePlayheadFrame(engine)` — current frame, + updates every rAF tick during playback +- `useIsPlaying(engine)` — playback state +- `useChange(engine)` — StateChange diff +- `usePlayhead(engine)` — full playhead state +- `usePlayheadEvent(engine, type, handler)` +- `useVirtualWindow(engine, w, s, ppf)` +- `useVisibleClips(engine, window)` +- `useToolRouter(engine, options)` + +**ToolRouter** +- `createToolRouter()` — React pointer/key + event → TimelinePointerEvent/KeyEvent +- rAF throttle on onPointerMove only +- Option Y: pointerLeave → handlePointerUp + + handlePointerLeave + clearProvisional +- `useToolRouter()` hook with stable ref + +**Selector isolation** +- Proven: updating clip A does not re-render + component watching clip B (toBe test) +- `historyFlags` cache prevents spurious + re-renders on unchanged undo/redo state +- `stableTrackIds` only recreates on actual + track list change + +### Tests +- Phase R adds 97 react tests +- 1039 total tests across core + react +- Integration suite: 27 tests covering + full dispatch→hook→re-render round-trips + +--- + ## [0.6.0] — Phase 7 Complete — February 27, 2025 ### Added @@ -292,7 +424,7 @@ _No unreleased changes._ - play() seeks to startFrame - prerollFrames on entry - 'loop-point' event on wrap -**React Hooks (@timeline/react)** +**React Hooks (@webpacked-timeline/react)** - `usePlayhead(engine)` — useSyncExternalStore, stable action callbacks - `usePlayheadEvent(engine, type, handler)` — @@ -308,7 +440,7 @@ _No unreleased changes._ ## [0.4.0] — Phase 4 Complete — February 27, 2025 -### @timeline/core +### @webpacked-timeline/core #### Added diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9462af --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Timeline Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 8f743a0..e6fc797 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,94 @@ # @timeline -A professional open-source NLE (Non-Linear Editor) -timeline engine for the web. +Professional open-source NLE (Non-Linear Editor) timeline engine for the web. ## Packages -| Package | Description | Install | +| Package | Description | Version | |---------|-------------|---------| -| `@timeline/core` | Headless TypeScript engine | `npm i @timeline/core` | -| `@timeline/react` | React adapter + hooks | `npm i @timeline/react` | -| `@timeline/ui` | shadcn-style components | `npx timeline-ui add timeline` | +| [`@webpacked-timeline/core`](packages/core) | Headless TypeScript engine | 1.0.0-beta.1 | +| [`@webpacked-timeline/react`](packages/react) | React adapter + hooks | 1.0.0-beta.1 | +| [`@webpacked-timeline/ui`](packages/ui) | DaVinci-style UI preset | 1.0.0-beta.1 | + +## Quick Start + +```bash +npm install @webpacked-timeline/ui @webpacked-timeline/react @webpacked-timeline/core +``` + +```tsx +import { DaVinciEditor } from '@webpacked-timeline/ui'; +import '@webpacked-timeline/ui/styles/davinci'; +import { TimelineEngine } from '@webpacked-timeline/react'; +import { createTimelineState, createTimeline, toFrame, frameRate } from '@webpacked-timeline/core'; + +const engine = new TimelineEngine({ + initialState: createTimelineState({ + timeline: createTimeline({ + id: 'tl-1', + name: 'My Timeline', + fps: frameRate(30), + duration: toFrame(9000), + }), + }), +}); + +export default function App() { + return ; +} +``` ## Architecture + +``` Your App -└── @timeline/ui (components you own) -└── @timeline/react (hooks + engine) -└── @timeline/core (pure TS engine) +└── @webpacked-timeline/ui → DaVinci-style components (React) + └── @webpacked-timeline/react → Hooks, context, TimelineEngine + └── @webpacked-timeline/core → Pure TypeScript engine (zero deps) +``` -@timeline/core is framework-agnostic. It runs in -the browser, Node.js, Web Workers, and Electron. -React is never imported by core. +- **@webpacked-timeline/core** is framework-agnostic. Runs in browser, Node.js, Web Workers, Electron. +- **@webpacked-timeline/react** provides `TimelineEngine` (wires core's dispatcher, history, tools, playback) and 20+ hooks. +- **@webpacked-timeline/ui** provides drop-in `DaVinciEditor` with toolbar, ruler, tracks, clips, playhead, and full keyboard shortcuts. -## Quick Start -```bash -# Install engine + adapter -npm install @timeline/core @timeline/react +## Features + +- 40+ atomic editing operations +- 12 professional tools (Selection, Razor, Trim, Slip, Slide, etc.) +- Undo/redo with transaction compression +- Playback engine with J/K/L shuttle +- Export to OTIO, EDL, AAF, FCP XML +- SRT/VTT subtitle import +- Snap system, virtual windowing, interval tree +- Full CSS variable theming +- 850+ tests, zero TypeScript errors -# Copy UI components into your project -npx timeline-ui add timeline -# Components land in ./components/timeline/ -# You own the code — edit freely. +## Development + +```bash +pnpm install +pnpm --filter @webpacked-timeline/core test # Run core tests +pnpm --filter @webpacked-timeline/react test # Run react tests +pnpm --filter @webpacked-timeline/ui build # Build UI package +cd apps/demo && pnpm dev # Run demo app ``` ## Status +Feature-complete. All phases delivered: + | Phase | Description | Status | |-------|-------------|--------| | 0 | Foundation — types, dispatch, history | ✅ | -| 1 | Tool scaffolding + React adapter scaffold | ✅ | +| 1 | Tool scaffolding + React adapter | ✅ | | 2 | Core tools — Select, Razor, Trim, Slip, Delete, Insert | ✅ | | 3 | Markers, BeatGrid, Generators, Captions, SRT/VTT | ✅ | | 4 | Effects, Keyframes, Transitions, Track Groups | ✅ | -| 5 | Serialization — JSON, OTIO, EDL, AAF, FCPXML | ✅ | +| 5 | Serialization — JSON, OTIO, EDL, AAF, FCP XML | ✅ | | 6 | Playback engine — PlayheadController, pipeline contracts | ✅ | | 7 | Performance — interval tree, compression, benchmarks | ✅ | -| R | @timeline/react — full adapter buildout | 🔄 Next | -| U | @timeline/ui — shadcn-style components | ⬜ | - -**@timeline/core — Feature Complete** -942 tests · 0 TypeScript errors +| R | @webpacked-timeline/react — full adapter buildout | ✅ | +| U | @webpacked-timeline/ui — DaVinci preset | ✅ | ## Contributing diff --git a/apps/demo/Icons/audio clip.svg b/apps/demo/Icons/audio clip.svg new file mode 100644 index 0000000..c0df554 --- /dev/null +++ b/apps/demo/Icons/audio clip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/blade.svg b/apps/demo/Icons/blade.svg new file mode 100644 index 0000000..15ec3fc --- /dev/null +++ b/apps/demo/Icons/blade.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/hand.svg b/apps/demo/Icons/hand.svg new file mode 100644 index 0000000..2403cbf --- /dev/null +++ b/apps/demo/Icons/hand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/lock.svg b/apps/demo/Icons/lock.svg new file mode 100644 index 0000000..65c4df6 --- /dev/null +++ b/apps/demo/Icons/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/mute active.svg b/apps/demo/Icons/mute active.svg new file mode 100644 index 0000000..8ec849a --- /dev/null +++ b/apps/demo/Icons/mute active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/mute inactive.svg b/apps/demo/Icons/mute inactive.svg new file mode 100644 index 0000000..3435a3e --- /dev/null +++ b/apps/demo/Icons/mute inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/pointer.svg b/apps/demo/Icons/pointer.svg new file mode 100644 index 0000000..d48f965 --- /dev/null +++ b/apps/demo/Icons/pointer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/redo.svg b/apps/demo/Icons/redo.svg new file mode 100644 index 0000000..f0ac0a1 --- /dev/null +++ b/apps/demo/Icons/redo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/roll.svg b/apps/demo/Icons/roll.svg new file mode 100644 index 0000000..ad32aad --- /dev/null +++ b/apps/demo/Icons/roll.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/scissors.svg b/apps/demo/Icons/scissors.svg new file mode 100644 index 0000000..cba8102 --- /dev/null +++ b/apps/demo/Icons/scissors.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/slide.svg b/apps/demo/Icons/slide.svg new file mode 100644 index 0000000..c713a89 --- /dev/null +++ b/apps/demo/Icons/slide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/slip.svg b/apps/demo/Icons/slip.svg new file mode 100644 index 0000000..f888907 --- /dev/null +++ b/apps/demo/Icons/slip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/solo active.svg b/apps/demo/Icons/solo active.svg new file mode 100644 index 0000000..49ebc83 --- /dev/null +++ b/apps/demo/Icons/solo active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/solo inactive.svg b/apps/demo/Icons/solo inactive.svg new file mode 100644 index 0000000..87a33fb --- /dev/null +++ b/apps/demo/Icons/solo inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/undo.svg b/apps/demo/Icons/undo.svg new file mode 100644 index 0000000..e8bcb60 --- /dev/null +++ b/apps/demo/Icons/undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/unlock.svg b/apps/demo/Icons/unlock.svg new file mode 100644 index 0000000..8678011 --- /dev/null +++ b/apps/demo/Icons/unlock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/video-clip.svg b/apps/demo/Icons/video-clip.svg new file mode 100644 index 0000000..7599347 --- /dev/null +++ b/apps/demo/Icons/video-clip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/video-off.svg b/apps/demo/Icons/video-off.svg new file mode 100644 index 0000000..deb8be0 --- /dev/null +++ b/apps/demo/Icons/video-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/video.svg b/apps/demo/Icons/video.svg new file mode 100644 index 0000000..7958070 --- /dev/null +++ b/apps/demo/Icons/video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/index.html b/apps/demo/index.html index dcafd92..3c52d35 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -3,7 +3,7 @@ - Timeline Demo - Architecture Validation + @timeline demo
diff --git a/apps/demo/package.json b/apps/demo/package.json index d190a5e..a79fd39 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -1,17 +1,17 @@ { - "name": "@timeline/demo", + "name": "@webpacked-timeline/demo", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc --noEmit && vite build", "preview": "vite preview" }, "dependencies": { - "@timeline/core": "workspace:*", - "@timeline/react": "workspace:*", - "@timeline/ui": "workspace:*", + "@webpacked-timeline/core": "workspace:*", + "@webpacked-timeline/react": "workspace:*", + "@webpacked-timeline/ui": "workspace:*", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -19,9 +19,8 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", - "autoprefixer": "^10.4.16", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", + "@tailwindcss/vite": "^4.0.0", + "tailwindcss": "^4.0.0", "typescript": "^5.3.0", "vite": "^5.0.0" } diff --git a/apps/demo/postcss.config.js b/apps/demo/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/apps/demo/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index afe7af7..e22ffdf 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -1,329 +1,21 @@ /** - * Demo App - Architecture Validation - * - * Minimal timeline editor to validate: - * - Core engine integration - * - React adapter functionality - * - UI package ergonomics - * - State consistency + * Demo — imports the full DaVinci editor from @webpacked-timeline/ui. + * + * The entire 1600+ line single-file implementation is now a clean + * library component. This file exists only to provide the engine + * singleton and mount the editor. */ +import { DaVinciEditor } from '@webpacked-timeline/ui'; +import '@webpacked-timeline/ui/styles/davinci'; +import { engine, setEnginePixelsPerFrame, setOnZoomChange } from './engine'; -import { - TimelineEngine, - createTimeline, - createTimelineState, - createTrack, - createClip, - createAsset, - frame, - frameRate, -} from '@timeline/core'; -import { TimelineProvider, useTimeline } from '@timeline/react'; -import { Timeline } from '@timeline/ui'; - -// Import internal utilities for demo -import { - generateTimelineId, - generateTrackId, - generateClipId, - generateAssetId, -} from '@timeline/core/internal'; - -// Create initial timeline -const timeline = createTimeline({ - id: generateTimelineId(), - name: 'Demo Timeline', - fps: frameRate(30), - duration: frame(9000), // 300 seconds @ 30fps - tracks: [], -}); - -// Create initial state (markers and work area can be added via UI) -const initialState = createTimelineState({ - timeline, -}); - -// Create engine with initial state -const engine = new TimelineEngine(initialState); - -// Add initial track -const track1 = createTrack({ - id: generateTrackId(), - name: 'Video Track 1', - type: 'video', -}); -engine.addTrack(track1); - -// Add an audio track for testing type enforcement -const track2 = createTrack({ - id: generateTrackId(), - name: 'Audio Track 1', - type: 'audio', -}); -engine.addTrack(track2); - -// Register video asset -const asset1 = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'video.mp4', -}); -engine.registerAsset(asset1); - -// Register audio asset -const asset2 = createAsset({ - id: generateAssetId(), - type: 'audio', - duration: frame(200), - sourceUrl: 'audio.mp3', -}); -engine.registerAsset(asset2); - -// Add initial video clip -const clip1 = createClip({ - id: generateClipId(), - assetId: asset1.id, - trackId: track1.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), -}); -engine.addClip(track1.id, clip1); - -// Add initial audio clip -const clip2 = createClip({ - id: generateClipId(), - assetId: asset2.id, - trackId: track2.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), -}); -engine.addClip(track2.id, clip2); - -function Controls() { - const { state, engine } = useTimeline(); - - const handleAddVideoTrack = () => { - const videoTracks = state.timeline.tracks.filter(t => t.type === 'video'); - const track = createTrack({ - id: generateTrackId(), - name: `Video Track ${videoTracks.length + 1}`, - type: 'video', - }); - engine.addTrack(track); - }; - - const handleAddAudioTrack = () => { - const audioTracks = state.timeline.tracks.filter(t => t.type === 'audio'); - const track = createTrack({ - id: generateTrackId(), - name: `Audio Track ${audioTracks.length + 1}`, - type: 'audio', - }); - engine.addTrack(track); - }; - - const handleAddVideoClip = () => { - const videoTrack = state.timeline.tracks.find(t => t.type === 'video'); - if (!videoTrack) { - alert('Add a video track first!'); - return; - } - - // Find the last clip on the track to position the new clip after it - const existingClips = videoTrack.clips; - let startFrame = frame(0); - if (existingClips.length > 0) { - const lastClip = existingClips[existingClips.length - 1]; - startFrame = frame(lastClip.timelineEnd + 10); // Add 10 frames gap - } - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(150), - sourceUrl: 'video-clip.mp4', - }); - engine.registerAsset(asset); - - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: videoTrack.id, - timelineStart: startFrame, - timelineEnd: frame(startFrame + 150), - mediaIn: frame(0), - mediaOut: frame(150), - }); - engine.addClip(videoTrack.id, clip); - }; - - const handleAddAudioClip = () => { - const audioTrack = state.timeline.tracks.find(t => t.type === 'audio'); - if (!audioTrack) { - alert('Add an audio track first!'); - return; - } - - // Find the last clip on the track to position the new clip after it - const existingClips = audioTrack.clips; - let startFrame = frame(0); - if (existingClips.length > 0) { - const lastClip = existingClips[existingClips.length - 1]; - startFrame = frame(lastClip.timelineEnd + 10); // Add 10 frames gap - } - - const asset = createAsset({ - id: generateAssetId(), - type: 'audio', - duration: frame(150), - sourceUrl: 'audio-clip.mp3', - }); - engine.registerAsset(asset); - - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: audioTrack.id, - timelineStart: startFrame, - timelineEnd: frame(startFrame + 150), - mediaIn: frame(0), - mediaOut: frame(150), - }); - engine.addClip(audioTrack.id, clip); - }; - - const buttonStyle = { - padding: '6px 12px', - backgroundColor: '#27272a', - color: '#d4d4d8', - border: '1px solid #3f3f46', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '14px', - }; - - return ( -
- {/* Track Controls */} -
- - -
- -
- - {/* Clip Controls */} -
- - -
- -
- - {/* History Controls */} -
- - -
- -
- - {/* Info & Shortcuts */} -
-
- Clips: {state.timeline.tracks.reduce((sum, t) => sum + t.clips.length, 0)} - Tracks: {state.timeline.tracks.length} -
- -
- ⌘/Ctrl+C: Copy - ⌘/Ctrl+V: Paste - ⌘/Ctrl+A: Select All - Del: Delete - Esc: Deselect - ←/→: Playhead -
-
-
- ); -} - -function App() { +export function App() { return ( - -
-
-

- Timeline Demo -

-

- UI Package Integration Validation -

-
- -
- -
-
-
+ ); } - -export default App; diff --git a/apps/demo/src/app.css b/apps/demo/src/app.css new file mode 100644 index 0000000..e28acc5 --- /dev/null +++ b/apps/demo/src/app.css @@ -0,0 +1,122 @@ +@import "tailwindcss"; + +/* ── @webpacked-timeline/ui DaVinci Resolve design tokens ── */ +@theme { + /* Track */ + --color-tl-track-video: hsl(220 15% 14%); + --color-tl-track-audio: hsl(220 12% 13%); + --color-tl-track-border: hsl(220 13% 19%); + --color-tl-track-label: hsl(220 13% 10%); + --color-tl-track-label-text: hsl(220 10% 62%); + + /* Clip */ + --color-tl-clip-video: hsl(213 30% 36%); + --color-tl-clip-video-top: hsl(213 30% 42%); + --color-tl-clip-audio: hsl(198 30% 30%); + --color-tl-clip-audio-top: hsl(198 30% 36%); + --color-tl-clip-selected: hsl(213 75% 52%); + --color-tl-clip-text: hsl(0 0% 92%); + --color-tl-clip-text-dim: hsl(0 0% 65%); + + /* Playhead */ + --color-tl-playhead: hsl(0 88% 56%); + + /* Ruler */ + --color-tl-ruler: hsl(220 13% 8%); + --color-tl-ruler-text: hsl(220 10% 52%); + --color-tl-ruler-tick: hsl(220 13% 30%); + --color-tl-ruler-tick-major: hsl(220 13% 48%); + + /* Toolbar */ + --color-tl-toolbar: hsl(220 13% 11%); + --color-tl-toolbar-border: hsl(220 13% 17%); + --color-tl-toolbar-btn: hsl(220 13% 22%); + --color-tl-toolbar-active: hsl(213 45% 32%); + --color-tl-toolbar-text: hsl(220 10% 68%); + + /* Timecode */ + --color-tl-timecode: hsl(35 85% 62%); + + /* Waveform */ + --color-tl-waveform: hsl(198 60% 42%); + --color-tl-waveform-rms: hsl(198 60% 30%); + + /* Keyframe */ + --color-tl-keyframe: hsl(45 93% 55%); + + /* Sizes */ + --spacing-tl-track-video: 52px; + --spacing-tl-track-audio: 44px; + --spacing-tl-ruler: 24px; + --spacing-tl-label: 160px; +} + +/* ── CSS variables (for runtime JS access) ── */ +:root { + --tl-track-height-video: 52px; + --tl-track-height-audio: 44px; + --tl-ruler-height: 24px; + --tl-label-width: 160px; + --tl-clip-radius: 2px; + --tl-playhead-width: 1px; + --tl-track-bg-video: var(--color-tl-track-video); + --tl-track-bg-audio: var(--color-tl-track-audio); + --tl-track-border: var(--color-tl-track-border); + --tl-track-label-bg: var(--color-tl-track-label); + --tl-track-label-text: var(--color-tl-track-label-text); + --tl-clip-bg-video: var(--color-tl-clip-video); + --tl-clip-bg-audio: var(--color-tl-clip-audio); + --tl-clip-bg-selected: var(--color-tl-clip-selected); + --tl-clip-bg-provisional: hsl(213 30% 36% / 0.5); + --tl-clip-border: hsl(213 30% 50% / 0.3); + --tl-clip-border-radius: var(--tl-clip-radius); + --tl-clip-text: var(--color-tl-clip-text); + --tl-clip-text-sm: var(--color-tl-clip-text-dim); + --tl-playhead-color: var(--color-tl-playhead); + --tl-playhead-head-size: 6px; + --tl-ruler-bg: var(--color-tl-ruler); + --tl-ruler-text: var(--color-tl-ruler-text); + --tl-ruler-tick: var(--color-tl-ruler-tick); + --tl-ruler-tick-major: var(--color-tl-ruler-tick-major); + --tl-toolbar-bg: var(--color-tl-toolbar); + --tl-toolbar-border: var(--color-tl-toolbar-border); + --tl-toolbar-btn-bg: var(--color-tl-toolbar-btn); + --tl-toolbar-btn-active: var(--color-tl-toolbar-active); + --tl-toolbar-btn-text: var(--color-tl-toolbar-text); + --tl-toolbar-btn-radius: 3px; + --tl-font-mono: ui-monospace, monospace; + --tl-font-size-sm: 10px; + --tl-timecode-color: var(--color-tl-timecode); + --tl-waveform-color: var(--color-tl-waveform); + --tl-waveform-rms: var(--color-tl-waveform-rms); +} + +/* ── Scrollbar styling ── */ +.tl-scroll-area::-webkit-scrollbar { + height: 8px; + width: 8px; +} + +.tl-scroll-area::-webkit-scrollbar-track { + background: hsl(220 13% 9%); +} + +.tl-scroll-area::-webkit-scrollbar-thumb { + background: hsl(220 13% 28%); + border-radius: 4px; +} + +.tl-scroll-area::-webkit-scrollbar-thumb:hover { + background: hsl(220 13% 35%); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: hsl(220 13% 9%); + color: hsl(220 13% 85%); + font-family: system-ui, -apple-system, sans-serif; +} diff --git a/apps/demo/src/engine.ts b/apps/demo/src/engine.ts new file mode 100644 index 0000000..eba1376 --- /dev/null +++ b/apps/demo/src/engine.ts @@ -0,0 +1,95 @@ +import { TimelineEngine } from '@webpacked-timeline/react'; +import { buildMockState } from './mock-data'; +import type { PipelineConfig } from '@webpacked-timeline/core'; +import { browserClock } from '@webpacked-timeline/core'; + +/** + * Singleton engine instance for the demo app. + * + * _ppf is a plain module-level variable captured by the + * getPixelsPerFrame closure. It is safe to read during the + * constructor (unlike a self-reference to `engine` which is + * still in the TDZ at construction time). + * + * Call setEnginePixelsPerFrame(v) whenever zoom changes so + * tool contexts always receive the current ppf. + */ +const DEFAULT_PPF = 4; +let _ppf = DEFAULT_PPF; + +/** Late-bound zoom callback — set by the React component on mount. */ +let _onZoomChange: (ppf: number) => void = () => {}; + +export function setEnginePixelsPerFrame(ppf: number): void { + _ppf = ppf; +} + +export function setOnZoomChange(cb: (ppf: number) => void): void { + _onZoomChange = cb; +} + +/** + * Stub pipeline — enables PlaybackEngine without real media decoding. + * The demo only needs the playhead to tick; no actual frames are rendered. + */ +const stubPipeline: PipelineConfig = { + videoDecoder: async (req) => ({ + clipId: req.clipId, + mediaFrame: req.mediaFrame, + bitmap: null, + width: 1920, + height: 1080, + }), + compositor: async (req) => ({ + timelineFrame: req.timelineFrame, + bitmap: null, + }), +}; + +/** + * Notifying clock — wraps browserClock so that every rAF tick also + * triggers an engine snapshot rebuild + React notification. + * + * PlayheadController in core updates its internal currentFrame on each + * rAF frame but does not emit an event for normal frame advancement. + * Wrapping the clock lets us call seekTo(currentFrame) after each tick, + * which emits a 'seek' event → engine listener → rebuildSnapshot → notify. + */ +let _afterTick: (() => void) | null = null; + +const notifyingClock = { + requestFrame: (cb: (ts: number) => void) => + browserClock.requestFrame((ts: number) => { + cb(ts); + _afterTick?.(); + }), + cancelFrame: (id: number) => browserClock.cancelFrame(id), + now: () => browserClock.now(), +}; + +export const engine = new TimelineEngine({ + initialState: buildMockState(), + onZoomChange: (ppf: number) => _onZoomChange(ppf), + getPixelsPerFrame: () => _ppf, + pipeline: stubPipeline, + clock: notifyingClock, +}); + +/** + * After each PlayheadController tick, force an engine notification by + * seeking to the current frame. This is a data no-op (same frame value) + * but emits the 'seek' event needed to trigger snapshot rebuild + React + * re-render. Only fires when the frame has actually changed to avoid + * unnecessary work. + */ +let _lastNotifiedFrame = -1; +_afterTick = () => { + const pb = engine.playbackEngine; + if (pb?.getState().isPlaying) { + const current = engine.getPlayheadFrame() as number; + if (current !== _lastNotifiedFrame) { + _lastNotifiedFrame = current; + engine.seekTo(engine.getPlayheadFrame()); + } + } +}; diff --git a/apps/demo/src/icons.tsx b/apps/demo/src/icons.tsx new file mode 100644 index 0000000..b534111 --- /dev/null +++ b/apps/demo/src/icons.tsx @@ -0,0 +1,204 @@ +/** + * Tabler-based SVG icon components for the demo toolbar. + * + * Sources: apps/demo/Icons/*.svg (Tabler Icons, outline style). + * Missing icons (playback, zoom, trim) are hand-drawn in the same + * 24×24 / stroke-1.5 / round-cap style for visual consistency. + */ +import React from 'react'; + +type IconProps = { size?: number; color?: string; strokeWidth?: number }; + +const defaults = { size: 16, color: 'currentColor', strokeWidth: 1.5 }; + +function svgProps(p: IconProps) { + const s = p.size ?? defaults.size; + const c = p.color ?? defaults.color; + const sw = p.strokeWidth ?? defaults.strokeWidth; + return { + xmlns: 'http://www.w3.org/2000/svg', + width: s, + height: s, + viewBox: '0 0 24 24', + fill: 'none', + stroke: c, + strokeWidth: sw, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + style: { display: 'block' } as React.CSSProperties, + }; +} + +// ── Tool icons ───────────────────────────────────────────────────────────── + +/** Selection (pointer) — from pointer.svg */ +export function IconPointer(p: IconProps = {}) { + return ( + + + + + ); +} + +/** Razor (scissors) — from scissors.svg */ +export function IconScissors(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +/** Ripple-trim — bracket-arrows (custom, Tabler style) */ +export function IconRippleTrim(p: IconProps = {}) { + return ( + + + {/* left bracket */} + + + + {/* right arrows */} + + + ); +} + +/** Roll-trim (arrows-left-right) — from roll.svg */ +export function IconRollTrim(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +/** Slip (arrows-move-horizontal) — from slip.svg */ +export function IconSlip(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +/** Slide (caret-left-right) — from slide.svg (filled) */ +export function IconSlide(p: IconProps = {}) { + const s = p.size ?? defaults.size; + const c = p.color ?? defaults.color; + return ( + + + + + + ); +} + +/** Hand (hand-stop) — from hand.svg */ +export function IconHand(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +// ── Playback icons ───────────────────────────────────────────────────────── + +/** Play — right-facing triangle */ +export function IconPlayerPlay(p: IconProps = {}) { + return ( + + + + + ); +} + +/** Pause — two vertical bars */ +export function IconPlayerPause(p: IconProps = {}) { + return ( + + + + + + ); +} + +// ── Edit icons ───────────────────────────────────────────────────────────── + +/** Undo (arrow-back-up) — from undo.svg */ +export function IconUndo(p: IconProps = {}) { + return ( + + + + + + ); +} + +/** Redo (arrow-forward-up) — from redo.svg */ +export function IconRedo(p: IconProps = {}) { + return ( + + + + + + ); +} + +// ── Zoom icons ───────────────────────────────────────────────────────────── + +/** Zoom in */ +export function IconZoomIn(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +/** Zoom out */ +export function IconZoomOut(p: IconProps = {}) { + return ( + + + + + + + ); +} diff --git a/apps/demo/src/main.tsx b/apps/demo/src/main.tsx index 80ec9b1..013ce5f 100644 --- a/apps/demo/src/main.tsx +++ b/apps/demo/src/main.tsx @@ -1,10 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import App from './App'; -import './styles.css'; +import { App } from './app'; +import './app.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + , ); diff --git a/apps/demo/src/mock-data.ts b/apps/demo/src/mock-data.ts new file mode 100644 index 0000000..fdb4343 --- /dev/null +++ b/apps/demo/src/mock-data.ts @@ -0,0 +1,129 @@ +import { + createTimelineState, + createTimeline, + createTrack, + createClip, + createAsset, + toTrackId, + toClipId, + toAssetId, + toFrame, + frameRate, +} from '@webpacked-timeline/core'; +import type { AssetRegistry } from '@webpacked-timeline/core'; + +const fps = 30 as const; +const duration = toFrame(fps * 120); // 2 minutes + +export function buildMockState() { + const assetMap = new Map(); + + function addAsset( + id: string, + name: string, + dur: number, + mediaType: 'video' | 'audio', + ) { + const asset = createAsset({ + id, + name, + mediaType, + filePath: `generator://${id}`, + intrinsicDuration: toFrame(dur), + nativeFps: frameRate(fps), + sourceTimecodeOffset: toFrame(0), + }); + assetMap.set(id, asset); + } + + const clips_v1 = [ + { id: 'c1', start: 0, dur: 90, label: 'Intro' }, + { id: 'c2', start: 100, dur: 120, label: 'Scene 1' }, + { id: 'c3', start: 230, dur: 80, label: 'Scene 2' }, + { id: 'c4', start: 320, dur: 150, label: 'Scene 3' }, + ].map((c) => { + const aid = `asset-${c.id}`; + addAsset(aid, c.label, c.dur, 'video'); + return createClip({ + id: toClipId(c.id), + assetId: toAssetId(aid), + trackId: toTrackId('v1'), + timelineStart: toFrame(c.start), + timelineEnd: toFrame(c.start + c.dur), + mediaIn: toFrame(0), + mediaOut: toFrame(c.dur), + name: c.label, + }); + }); + + const clips_v2 = [ + { id: 'c5', start: 50, dur: 60, label: 'B-Roll 1' }, + { id: 'c6', start: 200, dur: 100, label: 'B-Roll 2' }, + ].map((c) => { + const aid = `asset-${c.id}`; + addAsset(aid, c.label, c.dur, 'video'); + return createClip({ + id: toClipId(c.id), + assetId: toAssetId(aid), + trackId: toTrackId('v2'), + timelineStart: toFrame(c.start), + timelineEnd: toFrame(c.start + c.dur), + mediaIn: toFrame(0), + mediaOut: toFrame(c.dur), + name: c.label, + }); + }); + + const clips_a1 = [{ id: 'c7', start: 0, dur: 300, label: 'Music' }].map( + (c) => { + const aid = `asset-${c.id}`; + addAsset(aid, c.label, c.dur, 'audio'); + return createClip({ + id: toClipId(c.id), + assetId: toAssetId(aid), + trackId: toTrackId('a1'), + timelineStart: toFrame(c.start), + timelineEnd: toFrame(c.start + c.dur), + mediaIn: toFrame(0), + mediaOut: toFrame(c.dur), + name: c.label, + }); + }, + ); + + const clips_a2 = [ + { id: 'c8', start: 10, dur: 80, label: 'SFX 1' }, + { id: 'c9', start: 150, dur: 60, label: 'SFX 2' }, + ].map((c) => { + const aid = `asset-${c.id}`; + addAsset(aid, c.label, c.dur, 'audio'); + return createClip({ + id: toClipId(c.id), + assetId: toAssetId(aid), + trackId: toTrackId('a2'), + timelineStart: toFrame(c.start), + timelineEnd: toFrame(c.start + c.dur), + mediaIn: toFrame(0), + mediaOut: toFrame(c.dur), + name: c.label, + }); + }); + + const v1 = createTrack({ id: 'v1', name: 'Video 1', type: 'video', clips: clips_v1 }); + const v2 = createTrack({ id: 'v2', name: 'Video 2', type: 'video', clips: clips_v2 }); + const a1 = createTrack({ id: 'a1', name: 'Audio 1', type: 'audio', clips: clips_a1 }); + const a2 = createTrack({ id: 'a2', name: 'Audio 2', type: 'audio', clips: clips_a2 }); + + const timeline = createTimeline({ + id: 'tl-1', + name: 'Demo', + fps: frameRate(fps), + duration, + tracks: [v1, v2, a1, a2], + }); + + return createTimelineState({ + timeline, + assetRegistry: assetMap as unknown as AssetRegistry, + }); +} diff --git a/apps/demo/src/styles.css b/apps/demo/src/styles.css deleted file mode 100644 index 1022c45..0000000 --- a/apps/demo/src/styles.css +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Demo App - Minimal Global Styles - * - * Only essential app-level styles. - * Timeline components use Tailwind from @timeline/ui. - */ - -@tailwind base; -@tailwind components; -@tailwind utilities; - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -#root { - width: 100%; - height: 100vh; - overflow: hidden; -} diff --git a/apps/demo/tailwind.config.js b/apps/demo/tailwind.config.js deleted file mode 100644 index 2a7c6f2..0000000 --- a/apps/demo/tailwind.config.js +++ /dev/null @@ -1,12 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - "../../packages/ui/src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [], -} diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json index a7fc6fb..714041d 100644 --- a/apps/demo/tsconfig.json +++ b/apps/demo/tsconfig.json @@ -14,11 +14,18 @@ "noEmit": true, "jsx": "react-jsx", - /* Linting */ + /* Linting — relaxed for demo */ "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + + "paths": { + "@webpacked-timeline/core": ["../../packages/core/src/public-api.ts"], + "@webpacked-timeline/react": ["../../packages/react/src/index.ts"], + "@webpacked-timeline/ui": ["../../packages/ui/src/index.ts"], + "@webpacked-timeline/ui/styles/*": ["../../packages/ui/src/*.css"] + } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts index 4f0a8ee..c24777a 100644 --- a/apps/demo/vite.config.ts +++ b/apps/demo/vite.config.ts @@ -1,9 +1,20 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import tailwind from '@tailwindcss/vite'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ - plugins: [react()], - server: { - port: 3001, + plugins: [tailwind(), react()], + resolve: { + alias: { + '@webpacked-timeline/ui/styles/davinci': path.resolve(__dirname, '../../packages/ui/src/davinci.css'), + '@webpacked-timeline/ui/styles/tokens': path.resolve(__dirname, '../../packages/ui/src/tokens.css'), + '@webpacked-timeline/ui': path.resolve(__dirname, '../../packages/ui/src/index.ts'), + '@webpacked-timeline/core': path.resolve(__dirname, '../../packages/core/src/public-api.ts'), + '@webpacked-timeline/react': path.resolve(__dirname, '../../packages/react/src/index.ts'), + }, }, }); diff --git a/docs/CODEBASE_ARCHITECTURE.md b/docs/CODEBASE_ARCHITECTURE.md new file mode 100644 index 0000000..7eab31a --- /dev/null +++ b/docs/CODEBASE_ARCHITECTURE.md @@ -0,0 +1,621 @@ +# Timeline Editor — Complete Codebase Architecture + +> A professional, frame-accurate video/audio timeline editing system built as a TypeScript monorepo. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Monorepo Structure](#monorepo-structure) +3. [Package Dependency Graph](#package-dependency-graph) +4. [Core Package (`@webpacked-timeline/core`)](#core-package) +5. [React Adapter (`@webpacked-timeline/react`)](#react-adapter) +6. [UI Package (`@webpacked-timeline/ui`)](#ui-package) +7. [Demo Application (`@webpacked-timeline/demo-app`)](#demo-application) +8. [Data Flow: Top to Bottom](#data-flow-top-to-bottom) +9. [Type System](#type-system) +10. [Tool System](#tool-system) +11. [History & Undo/Redo](#history--undoredo) +12. [Snap System](#snap-system) +13. [Playback System](#playback-system) +14. [Serialization & Import/Export](#serialization--importexport) +15. [Invariant System](#invariant-system) +16. [Feature Matrix](#feature-matrix) + +--- + +## Overview + +This is a **framework-agnostic, deterministic, frame-based timeline editing kernel** with a React integration layer and UI component library. It follows a strict three-layer architecture: + +| Layer | Package | Responsibility | +|-------|---------|---------------| +| **Data Layer** | `@webpacked-timeline/core` | Pure state, dispatch, tools, history, validation, serialization | +| **Adapter Layer** | `@webpacked-timeline/react` | React integration: engine orchestrator, hooks, tool routing, context | +| **Presentation Layer** | `@webpacked-timeline/ui` | Visual components: tracks, clips, ruler, toolbar, playhead | +| **Application Layer** | `@webpacked-timeline/demo-app` | Demo app composing all layers into a working timeline editor | + +### Key Architectural Principles + +| Principle | Rule | +|-----------|------| +| **Three-layer law** | Core imports only stdlib + TypeScript. No React/DOM/UI. | +| **Single mutation entry** | Only `dispatch(state, transaction)` produces a new `TimelineState`. | +| **Strict immutability** | All state updates return new objects; no in-place mutation. | +| **Time type law** | All frame positions are `TimelineFrame` (branded integer); never raw `number`. | +| **Selector isolation** | Each React hook only re-renders when its specific slice of state changes. | + +--- + +## Monorepo Structure + +``` +timeline/ +├── packages/ +│ ├── core/ → @webpacked-timeline/core (pure TypeScript engine) +│ ├── react/ → @webpacked-timeline/react (React adapter layer) +│ ├── ui/ → @webpacked-timeline/ui (UI components) +│ └── demo/ → @webpacked-timeline/demo-app (working demo application) +├── apps/ +│ └── demo/ → Integration test demo (deprecated) +├── turbo.json → Turborepo build config +├── pnpm-workspace.yaml +└── package.json +``` + +--- + +## Package Dependency Graph + +``` +┌─────────────────────────────────────────────┐ +│ Application │ +│ @webpacked-timeline/demo-app │ +│ (composes all layers into working app) │ +└────────────┬───────────────┬────────────────┘ + │ │ + ▼ ▼ +┌────────────────┐ ┌────────────────────────┐ +│ @webpacked-timeline/ui │ │ (custom components) │ +│ (components) │ │ built per-app │ +└───────┬────────┘ └──────────┬─────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────┐ +│ @webpacked-timeline/react │ +│ TimelineEngine · Hooks · ToolRouter · │ +│ TimelineProvider · useSyncExternalStore │ +└────────────────────┬────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ @webpacked-timeline/core │ +│ dispatch · TimelineState · Tools · History │ +│ Snap · Playback · Serialization · Invariants│ +└─────────────────────────────────────────────┘ +``` + +--- + +## Core Package + +### Directory Map + +| Directory | Purpose | Key Files | +|-----------|---------|-----------| +| `engine/` | Mutation pipeline, history, playback, serialization, import/export | `dispatcher.ts`, `apply.ts`, `history.ts`, `playback-engine.ts` | +| `operations/` | High-level operation builders | `clip-operations.ts`, `track-operations.ts`, `ripple.ts` | +| `systems/` | Query helpers, asset registry, validation | `queries.ts`, `asset-registry.ts`, `validation.ts` | +| `tools/` | Framework-agnostic editing tools (12 built-in) | `selection.ts`, `razor.ts`, `slip.ts`, `ripple-trim.ts`, etc. | +| `types/` | All TypeScript types and factories (28 files) | `state.ts`, `clip.ts`, `track.ts`, `operations.ts`, `frame.ts` | +| `utils/` | Frame math, ID generation | `frame.ts`, `id.ts` | +| `validation/` | Pre-dispatch and post-dispatch checks | `invariants.ts`, `validators.ts` | + +### The Dispatch Pipeline + +This is the **only** way state changes: + +``` +Transaction { id, label, timestamp, operations[] } + │ + ▼ + dispatch(state, transaction) + │ + ┌───────────────┼───────────────┐ + │ For each operation: │ + │ 1. validateOperation(s, op) │ + │ 2. s = applyOperation(s, op)│ + └───────────────┼───────────────┘ + │ + checkInvariants(proposedState) + │ + ┌───────┴───────┐ + │ │ + violations? no violations + │ │ + { accepted: false } { accepted: true, nextState } +``` + +Rules: +- **All-or-nothing**: If any operation fails validation, zero operations are applied +- **Rolling state**: Each op validates against state after previous ops (needed for DELETE → INSERT×2) +- **Version bump**: `timeline.version` increments by 1 on each successful commit + +### Operation Primitives (58+ types) + +| Category | Operations | +|----------|-----------| +| **Clip** | `MOVE_CLIP`, `RESIZE_CLIP`, `SLICE_CLIP`, `DELETE_CLIP`, `INSERT_CLIP`, `SET_MEDIA_BOUNDS`, `SET_CLIP_ENABLED`, `SET_CLIP_REVERSED`, `SET_CLIP_SPEED`, `SET_CLIP_COLOR`, `SET_CLIP_NAME`, `SET_CLIP_TRANSFORM`, `SET_AUDIO_PROPERTIES` | +| **Track** | `ADD_TRACK`, `DELETE_TRACK`, `REORDER_TRACK`, `SET_TRACK_HEIGHT`, `SET_TRACK_NAME`, `SET_TRACK_BLEND_MODE`, `SET_TRACK_OPACITY` | +| **Transition** | `ADD_TRANSITION`, `DELETE_TRANSITION`, `SET_TRANSITION_DURATION`, `SET_TRANSITION_ALIGNMENT` | +| **Effect** | `ADD_EFFECT`, `REMOVE_EFFECT`, `REORDER_EFFECT`, `SET_EFFECT_ENABLED`, `SET_EFFECT_PARAM` | +| **Keyframe** | `ADD_KEYFRAME`, `MOVE_KEYFRAME`, `DELETE_KEYFRAME`, `SET_KEYFRAME_EASING` | +| **Asset** | `REGISTER_ASSET`, `UNREGISTER_ASSET`, `SET_ASSET_STATUS` | +| **Timeline** | `RENAME_TIMELINE`, `SET_TIMELINE_DURATION`, `SET_TIMELINE_START_TC`, `SET_SEQUENCE_SETTINGS` | +| **Marker** | `ADD_MARKER`, `MOVE_MARKER`, `DELETE_MARKER` | +| **In/Out** | `SET_IN_POINT`, `SET_OUT_POINT` | +| **Generator** | `INSERT_GENERATOR`, `ADD_CAPTION`, `EDIT_CAPTION`, `DELETE_CAPTION` | +| **Grouping** | `LINK_CLIPS`, `UNLINK_CLIPS`, `ADD_TRACK_GROUP`, `DELETE_TRACK_GROUP` | +| **Beat Grid** | `ADD_BEAT_GRID`, `REMOVE_BEAT_GRID` | + +--- + +## React Adapter + +### Architecture + +The React adapter wraps core into a `useSyncExternalStore`-compatible pattern: + +``` +┌──────────────────────────────────────────────┐ +│ TimelineEngine │ +│ │ +│ ┌─────────┐ ┌──────────┐ ┌───────────────┐ │ +│ │ History │ │ToolReg. │ │ ProvisionalMgr│ │ +│ │ Stack │ │(12 tools)│ │ (ghost state) │ │ +│ └────┬─────┘ └────┬─────┘ └──────┬────────┘ │ +│ │ │ │ │ +│ ┌────┴────┐ ┌─────┴─────┐ ┌──────┴──────┐ │ +│ │Snap Idx │ │ Track Idx │ │ Playback │ │ +│ │Manager │ │ │ │ Engine │ │ +│ └─────────┘ └───────────┘ └─────────────┘ │ +│ │ +│ subscribe() / getSnapshot() → EngineSnapshot │ +└──────────────────────────────────────────────┘ +``` + +### EngineSnapshot Shape + +```typescript +{ + state: TimelineState, // Current immutable state + provisional: ProvisionalState | null, // Ghost clips during drag + activeToolId: string, // Current tool ID + canUndo: boolean, + canRedo: boolean, + history: { canUndo, canRedo }, + trackIds: readonly string[], // Stable reference if unchanged + cursor: string, // CSS cursor for active tool + playhead: PlayheadState, // Frame position, playing, rate + change: StateChange, // Diff from previous state +} +``` + +### Hooks (Selector-Isolated) + +| Hook | Returns | Re-renders when | +|------|---------|-----------------| +| `useEngine()` | `TimelineEngine` | Never (stable ref) | +| `useTimeline()` | `Timeline` | Timeline object changes | +| `useTrackIds()` | `readonly string[]` | Tracks added/removed | +| `useTrack(id)` | `Track \| null` | That specific track changes | +| `useClip(id)` | `Clip \| null` | That specific clip changes (provisional-aware) | +| `useClips(engine, trackId)` | `readonly Clip[]` | Clips on that track change | +| `useActiveTool()` | `{ id, cursor }` | Tool switch or cursor changes | +| `useCanUndo()` / `useCanRedo()` | `boolean` | Undo/redo availability changes | +| `useProvisional()` | `ProvisionalState \| null` | During drag | +| `usePlayheadFrame(engine)` | `TimelineFrame` | Playhead position changes | +| `useIsPlaying(engine)` | `boolean` | Play state toggles | +| `useMarkers(engine)` | `readonly Marker[]` | Markers change | +| `useHistory(engine)` | `{ canUndo, canRedo }` | History state changes | +| `useToolRouter(engine, opts)` | `ToolRouterHandlers` | Stable (memoized) | +| `useVirtualWindow(engine, ...)` | `VirtualWindow` | Viewport changes | +| `useVisibleClips(engine, window)` | `VirtualClipEntry[]` | Visible clips change | + +### Tool Router Event Flow + +``` +DOM Event (pointer/keyboard) + │ + ▼ +useToolRouter handlers (React synthetic events) + │ + ▼ +Coordinate conversion: clientX/Y → frame + trackId + clipId + │ + ▼ +engine.handlePointerDown/Move/Up(converted, modifiers) + │ + ▼ +Active tool's onPointerDown/Move/Up(event, toolContext) + │ + ├── onPointerMove → ProvisionalState (ghost; never dispatches) + │ + └── onPointerUp → Transaction | null + │ + ▼ + dispatch(state, transaction) + │ + ▼ + queueMicrotask → rebuildSnapIndex +``` + +--- + +## UI Package + +### Component Hierarchy + +``` +TimelineRoot (engine, provides context, wires ToolRouter) +├── Toolbar (8 tools: Select, Razor, Trim, Roll, Slip, Slide, Hand, Zoom) +├── Ruler (timecode ticks, click-to-seek) +│ ├── MarkerPin* (point markers on ruler) +│ └── InOutHandles (in/out point drag handles) +├── Track* (per track — label area + clip container) +│ ├── Clip* (absolutely positioned, provisional-aware) +│ │ ├── ThumbnailStrip (video thumbnails) +│ │ ├── ClipLabel (name + duration) +│ │ ├── EffectLane (colored effect bands) +│ │ │ └── KeyframeDiamond* (draggable keyframe markers) +│ │ └── TransitionHandle (dissolve/wipe resize) +│ └── MarkerRange* (range marker overlays) +├── Playhead (frame line + scrub handle) +└── ZoomBar (+/- zoom controls) +``` + +### Hit-Testing via Data Attributes + +| Attribute | Component | Purpose | +|-----------|-----------|---------| +| `data-clip-id={id}` | Clip | Tool router identifies clicked clip | +| `data-track-id={id}` | Track/Clip | Tool router identifies target track | + +### Theming + +90+ CSS custom properties (`--tl-*` tokens) for full visual customization: +- **Dark Pro** (default): `registry/themes/dark-pro.css` +- **Light**: `registry/themes/light.css` +- Custom: Override any `--tl-*` variable in `:root` + +--- + +## Demo Application + +### How It Works + +``` +app.tsx +├── engine.ts → new TimelineEngine({ initialState: buildMockState() }) +├── mock-data.ts → 4 tracks, 9 clips, 9 assets @ 30fps +└── components/ + ├── TimelineRoot → Wires useToolRouter to DOM container + ├── Toolbar → 8 tool buttons calling engine.activateTool() + ├── Ruler → Timecode ticks, click-to-seek + ├── Track → Track rows with label + clip area + ├── Clip → Positioned blocks, provisional state overlay + ├── ClipLabel → Name + duration display + ├── Playhead → Red line + draggable handle + └── ZoomBar → Logarithmic zoom slider +``` + +--- + +## Data Flow: Top to Bottom + +### Complete Mutation Flow + +``` +1. User clicks/drags in UI + │ +2. DOM event captured by TimelineRoot (onPointerDown/Move/Up) + │ +3. useToolRouter converts to TimelinePointerEvent + (frame, trackId, clipId via data-* attributes) + │ +4. engine.handlePointerDown/Move/Up(event, modifiers) + │ +5. Active tool processes event: + ├── onPointerMove → returns ProvisionalState (ghost) + │ → engine stores it, hooks react + │ + └── onPointerUp → returns Transaction (or null) + │ +6. engine.dispatch(transaction) + │ +7. For each op in transaction: + a. validateOperation(rollingState, op) + b. applyOperation(rollingState, op) + │ +8. checkInvariants(proposedState) + │ +9. If accepted: + a. History push (with compression) + b. TrackIndex rebuild + c. SnapIndex schedule rebuild + d. PlaybackEngine update + e. Rebuild EngineSnapshot + f. Notify all subscribers + │ +10. useSyncExternalStore detects change + → selector picks relevant slice + → React re-renders only affected components +``` + +### Read Path + +``` +Component + │ + ▼ +useClip(clipId) / useTrack(trackId) / useTimeline() / etc. + │ + ▼ +useSyncExternalStore(engine.subscribe, () => selector(engine.getSnapshot())) + │ + ▼ +Selector extracts slice from EngineSnapshot + │ + ▼ +React compares with previous value (reference equality) + │ + ├── Same reference → no re-render + └── Different reference → re-render component +``` + +--- + +## Type System + +### Core State Shape + +```typescript +TimelineState { + timeline: Timeline { + id: string + name: string + fps: FrameRate + duration: TimelineFrame + tracks: Track[] { + id: TrackId + name: string + type: 'video' | 'audio' | 'subtitle' | 'title' + clips: Clip[] { + id: ClipId + assetId: AssetId + trackId: TrackId + timelineStart: TimelineFrame + timelineEnd: TimelineFrame + mediaIn: TimelineFrame + mediaOut: TimelineFrame + name?: string + speed?: number + effects?: Effect[] + transitions?: Transition[] + transform?: ClipTransform + audioProperties?: AudioProperties + } + height?: number + muted?: boolean + locked?: boolean + solo?: boolean + } + markers: Marker[] + inPoint?: TimelineFrame + outPoint?: TimelineFrame + beatGrid?: BeatGrid + version: number + } + assetRegistry: Map + schemaVersion: number +} +``` + +### Branded ID Types + +All IDs are branded strings for type safety: +`ClipId`, `TrackId`, `AssetId`, `EffectId`, `KeyframeId`, `TransitionId`, `ToolId`, `TrackGroupId`, `LinkGroupId`, `ProjectId`, `BinId`, `MarkerId`, `CaptionId` + +--- + +## Tool System + +### Built-in Tools (12) + +| Tool | ID | What It Does | Operations Generated | +|------|----|-------------|---------------------| +| **Selection** | `selection` | Click-select, drag-move, rubber-band | `MOVE_CLIP` | +| **Razor** | `razor` | Slice clip at frame | `DELETE_CLIP` + `INSERT_CLIP`×2 | +| **Ripple Trim** | `ripple-trim` | Trim edge + shift subsequent clips | `RESIZE_CLIP` + `MOVE_CLIP`×N | +| **Roll Trim** | `roll-trim` | Adjust adjacent edges together | `RESIZE_CLIP`×2 | +| **Slip** | `slip` | Move media within clip bounds | `SET_MEDIA_BOUNDS` | +| **Slide** | `slide` | Slide clip, adjust neighbors | `MOVE_CLIP` + `RESIZE_CLIP`×2 | +| **Ripple Delete** | `ripple-delete` | Delete + shift subsequent | `DELETE_CLIP` + `MOVE_CLIP`×N | +| **Ripple Insert** | `ripple-insert` | Insert + push subsequent | `MOVE_CLIP`×N + `INSERT_CLIP` | +| **Hand** | `hand` | Pan/scroll | (no transaction) | +| **Transition** | `transition` | Add/manage transitions | `ADD_TRANSITION` | +| **Keyframe** | `keyframe` | Add/move/delete keyframes | `ADD_KEYFRAME`, `MOVE_KEYFRAME`, `DELETE_KEYFRAME` | +| **Zoom** | `zoom` | Zoom in/out | (calls onZoomChange callback) | + +### Tool Interface + +```typescript +interface ITool { + id: ToolId; + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void; + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null; + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null; + onKeyDown(event: TimelineKeyEvent, ctx: ToolContext): Transaction | null; + onKeyUp(event: TimelineKeyEvent, ctx: ToolContext): void; + onCancel(): void; + getCursor(ctx: ToolContext): string; +} +``` + +--- + +## History & Undo/Redo + +### Dual API + +1. **Pure functions**: `createHistory`, `pushHistory`, `undo`, `redo`, `canUndo`, `canRedo` +2. **HistoryStack class**: Adds compression policies, checkpoints, persistence + +### Compression + +The `TransactionCompressor` merges consecutive similar operations (e.g., rapid MOVE_CLIPs during drag become one history entry). + +```typescript +CompressionPolicy { + maxAge: number; // Max age in ms for merge eligibility + mergeable: string[]; // Operation types that can be merged + maxConsecutiveMerges: number; +} +``` + +--- + +## Snap System + +### How It Works + +``` +buildSnapIndex(state, playheadFrame) + → Sorted array of snap points from clip boundaries, playhead, beat grid + +nearest(index, frame, radius, exclude?, allowedTypes?) + → Best snap point within radius (tiebreak: priority → sort order) + +toggleSnap(index, enabled) + → Pure toggle +``` + +### Priority Table + +| Source | Priority | +|--------|----------| +| Marker | 100 | +| In/Out Point | 90 | +| Clip Start/End | 80 | +| Playhead | 70 | +| Beat Grid | 50 | + +--- + +## Playback System + +``` +PlaybackEngine +├── PlayheadController (position, play/pause, seek, jog/shuttle) +├── Clock (browserClock / nodeClock / testClock) +├── Pipeline (VideoDecoder, AudioDecoder, Compositor) +└── State → { currentFrame, isPlaying, rate, loopRegion } +``` + +### Keyboard Shortcuts (via KeyboardHandler) + +| Key | Action | +|-----|--------| +| Space | Play/Pause | +| J | Shuttle reverse | +| K | Stop | +| L | Shuttle forward | +| ← / → | Step frame | +| Home / End | Go to start/end | + +--- + +## Serialization & Import/Export + +| Format | Import | Export | +|--------|--------|--------| +| JSON (native) | `deserializeTimeline` | `serializeTimeline` | +| OTIO | `importFromOTIO` | `exportToOTIO` | +| EDL | — | `exportToEDL` | +| AAF | — | `exportToAAF` | +| FCP XML | — | `exportToFCPXML` | +| SRT subtitles | `parseSRT` | — | +| VTT subtitles | `parseVTT` | — | + +--- + +## Invariant System + +Post-mutation checks that **reject** invalid state: + +| # | Check | Violation Type | +|---|-------|---------------| +| 1 | Schema version matches | `SCHEMA_VERSION_MISMATCH` | +| 2 | Clips sorted by `timelineStart` | `TRACK_NOT_SORTED` | +| 3 | No overlapping clips on same track | `OVERLAP` | +| 4 | Every clip's `assetId` exists | `ASSET_MISSING` | +| 5 | Asset `mediaType` matches track `type` | `TRACK_TYPE_MISMATCH` | +| 6 | `mediaIn >= 0`, `mediaOut <= intrinsicDuration` | `MEDIA_BOUNDS_INVALID` | +| 7 | Duration matches `(mediaOut - mediaIn) / speed` | `DURATION_MISMATCH` | +| 8 | `timelineEnd <= timeline.duration` | `CLIP_BEYOND_TIMELINE` | +| 9 | `speed > 0` | `SPEED_INVALID` | + +--- + +## Feature Matrix + +### Core → React → UI Coverage + +| Feature | Core | React Adapter | UI Components | Demo App | +|---------|:----:|:-------------:|:-------------:|:--------:| +| Timeline state management | ✅ | ✅ | — | ✅ | +| Clip CRUD | ✅ | ✅ (dispatch) | — | ✅ | +| Track CRUD | ✅ | ✅ (dispatch) | — | ✅ | +| Selection tool | ✅ | ✅ | ✅ | ✅ | +| Razor tool | ✅ | ✅ | ✅ | ✅ | +| Ripple trim | ✅ | ✅ | ✅ | ✅ | +| Roll trim | ✅ | ✅ | ✅ | ✅ | +| Slip tool | ✅ | ✅ | ✅ | ✅ | +| Slide tool | ✅ | ✅ | ✅ | ✅ | +| Hand tool | ✅ | ✅ | ✅ | ✅ | +| Zoom tool | ✅ | ✅ | ✅ | ✅ | +| Undo/Redo | ✅ | ✅ | — | ✅ | +| History compression | ✅ | ✅ | — | ✅ | +| Snap-to-edge | ✅ | ✅ | — | ✅ | +| Provisional/ghost state | ✅ | ✅ | ✅ | ✅ | +| Playhead display | ✅ | ✅ | ✅ | ✅ | +| Playhead seeking | ✅ | ✅ | ✅ | ✅ | +| Tool cursor feedback | ✅ | ✅ | ✅ | ✅ | +| Markers | ✅ | ✅ (read) | ✅ | — | +| Time ruler | — | — | ✅ | ✅ | +| Zoom controls | ✅ | — | ✅ | ✅ | +| Keyboard shortcuts | ✅ | ✅ | — | ✅ | +| Effects | ✅ | — | ✅ (display) | — | +| Keyframes | ✅ | — | ✅ (display) | — | +| Transitions | ✅ | — | ✅ (display) | — | +| Serialization | ✅ | — | — | — | +| OTIO/EDL/AAF export | ✅ | — | — | — | +| Subtitle import | ✅ | — | — | — | +| Project model | ✅ | — | — | — | +| Track groups | ✅ | — | — | — | +| Link groups | ✅ | — | — | — | +| Audio properties | ✅ | — | — | — | +| Clip transforms | ✅ | — | — | — | +| Waveform display | — | — | ✅ | — | +| Thumbnail strip | — | — | ✅ | — | +| Virtual scrolling | ✅ | ✅ | — | — | + +--- + +## How To Add a New Feature (End-to-End) + +1. **Core**: Add new `OperationPrimitive` type → implement in `applyOperation` → add validation in `validators.ts` → add invariant check if needed +2. **React**: The operation is already available via `engine.dispatch()`. Optionally add a dedicated hook for reading the new state +3. **UI**: Add component that reads via hook and renders. User interactions create transactions via tool router or direct dispatch +4. **Demo**: Wire the new component into the app layout diff --git a/docs/DEMO_DIAGNOSIS.md b/docs/DEMO_DIAGNOSIS.md new file mode 100644 index 0000000..0c1a1b5 --- /dev/null +++ b/docs/DEMO_DIAGNOSIS.md @@ -0,0 +1,105 @@ +# Demo app operations diagnosis + +Reference for manual checks. Hit-testing is now implemented in the adapter (see below). + +## Hit-testing fix (implemented) + +- **packages/react/src/adapter/tool-router.ts**: `convertPointerEvent` walks up the DOM from `e.target` and reads `data-clip-id` / `data-track-id` from elements. It sets `clipId`, `trackId`, and optional `edge` ('left' | 'right' | 'none') for clip edge hit zone. Tools now receive correct clipId/trackId when clicking clips or track empty space. +- **packages/demo**: Track clip area has `data-track-id`; Clip already had `data-clip-id` and `data-track-id` on the outer div. Diagnostic code (window.__tl_engine, dispatch logger, handlers log) has been removed. + +## How to run + +1. Start the demo: `pnpm --filter @webpacked-timeline/demo-app dev` +2. Open the app in the browser and open DevTools → Console. +3. Run through the actions below and note results. +4. Run the Part 2 console commands. +5. Inspect a clip element for `data-clip-id` / `data-track-id` (Part 4). +6. After clicking/dragging a clip, check whether any `dispatch:` log appeared (Part 5). + +--- + +## PART 1 — Console diagnosis + +| # | Action | What happened | Console errors/warnings | +|---|--------|----------------|-------------------------| +| 1 | Click a clip | _e.g. no highlight_ | _none_ | +| 2 | Toolbar "Razor" → click a clip | _e.g. no split_ | | +| 3 | Toolbar "Select" → drag a clip | _e.g. no move_ | | +| 4 | Click the ruler | _e.g. playhead moves / no change_ | | +| 5 | Drag zoom slider | _e.g. clips resize_ | | +| 6 | Press Space | _e.g. play/pause or nothing_ | | +| 7 | Press ArrowRight | _e.g. step forward or nothing_ | | + +--- + +## PART 2 — Engine in console + +```js +console.log(window.__tl_engine) // expect: engine object +window.__tl_engine.getActiveToolId() // expect: 'selection' +window.__tl_engine.getSnapshot().activeToolId // expect: matches above +``` + +Result: _e.g. engine present, getActiveToolId() returns 'selection'_ + +--- + +## PART 3 — ToolRouter handlers + +From the one-time log on load: + +**handlers keys:** _e.g. `['onPointerDown','onPointerMove','onPointerUp','onPointerLeave','onKeyDown']`_ + +The TimelineRoot div has `{...handlers}` (onPointerDown/Move/Up/Leave, onKeyDown). No other element should sit on top and block pointer events (check z-index / pointer-events in DevTools if clicks don’t reach the root). + +--- + +## PART 4 — Clip data attributes + +Right‑click a clip → Inspect. Check for: + +- `data-clip-id="..."` +- `data-track-id="..."` + +Result: _e.g. both present on the clip div_ + +--- + +## PART 5 — Dispatch on interaction + +When you click or drag a clip, does the console show a `dispatch: ...` line? + +Result: _e.g. No — dispatch is not called on clip click/drag_ + +--- + +## Root cause (why clip actions don’t work) + +The demo uses `useToolRouter` from `@webpacked-timeline/react`, which is backed by the **adapter** tool router (`packages/react/src/adapter/tool-router.ts`). That adapter: + +- Converts pointer events to `TimelinePointerEvent` with **frame** (from x and zoom). +- Always sets **trackId: null** and **clipId: null** (it has no layout or hit-test). + +Core tools (selection, razor, slip, etc.) all depend on `event.clipId` and/or `event.trackId`. When those are null they no-op (e.g. “if (event.clipId === null) return”), so: + +- Clicking a clip does not select it. +- Razor does not split. +- Select + drag does not move. + +The **full** tool router in `packages/react/src/tool-router.ts` does hit-testing: it takes a `getLayout()` that returns `timelineOriginX`, `pixelsPerFrame`, and **trackLayouts** (each track’s top/height in client coords), and it populates `frame`, `trackId`, and `clipId` via `frameAtX`, `trackAtY`, and `clipAtFrame`. That implementation is **not** currently exported from `@webpacked-timeline/react` (only the adapter is). So to fix the demo: + +1. **Option A**: Export the full `createToolRouter` from `@webpacked-timeline/react` (and a `useToolRouter` that uses it) and pass a `getLayout()` from the demo that computes track layouts from the DOM or from track positions (e.g. track index × track height + ruler height). +2. **Option B**: In the demo, use a local/copy of the full tool router and call it with `getLayout()` so that pointer events get correct `clipId`/`trackId` before being sent to the engine. + +After wiring layout and hit-testing, clip selection, razor, and drag should work; then you can re-check Parts 1 and 5 (dispatch should be called when committing a tool action). + +--- + +## SelectionTool and selection visual + +**SelectionTool** (`packages/core/src/tools/selection.ts`) keeps selection in a private instance Set; it does not dispatch SET_SELECTION and the engine has no selection in snapshot. The demo cannot show selection highlight until the engine exposes selection (e.g. useSelection hook). Clip click and drag work; the UI just will not show which clips are selected. + +## After diagnosis + +- Remove or guard the `console.log('handlers:', ...)` in `timeline-root.tsx` if you don’t want it in dev. +- Remove the `window.__tl_engine` assignment and the `dispatch` wrapper in `engine.ts` when you’re done debugging. diff --git a/docs/UNDERSTANDING_THE_CODE.md b/docs/UNDERSTANDING_THE_CODE.md index 51a82ef..5bf0d2d 100644 --- a/docs/UNDERSTANDING_THE_CODE.md +++ b/docs/UNDERSTANDING_THE_CODE.md @@ -8,12 +8,12 @@ A short guide to how this repo is structured and how to read it. - **Monorepo** (pnpm workspaces + Turbo). Three main packages: - **`packages/core`** — Timeline kernel: state, dispatch, operations, tools, snap. No React/DOM. - - **`packages/react`** — Adapter: `TimelineProvider`, hooks (`useTimeline`, `useClip`, …), tool router. Imports `@timeline/core` + React. - - **`packages/ui`** — Components: ``, ``, etc. Imports `@timeline/react` and `@timeline/core`. + - **`packages/react`** — Adapter: `TimelineProvider`, hooks (`useTimeline`, `useClip`, …), tool router. Imports `@webpacked-timeline/core` + React. + - **`packages/ui`** — Components: ``, ``, etc. Imports `@webpacked-timeline/react` and `@webpacked-timeline/core`. - **Rule:** Lower layers never import from higher layers. Core is the foundation. -- **Commands:** From repo root: `pnpm install`, `pnpm build`, `pnpm test`. Per-package: `pnpm --filter @timeline/core test`, etc. +- **Commands:** From repo root: `pnpm install`, `pnpm build`, `pnpm test`. Per-package: `pnpm --filter @webpacked-timeline/core test`, etc. --- diff --git a/package.json b/package.json index 7e19e26..5737ced 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "keywords": [], "author": "", - "license": "ISC", + "license": "MIT", "packageManager": "pnpm@10.28.2", "devDependencies": { "@changesets/cli": "^2.29.8", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000..0ad38e8 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to `@webpacked-timeline/core` are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.0.0-beta.1] - 2026-03-07 + +### Added +- Core state model: `Timeline`, `Track`, `Clip`, `Asset` with branded IDs (`ClipId`, `TrackId`, `AssetId`, `TimelineFrame`, `FrameRate`) +- Factory functions: `createTimeline`, `createTrack`, `createClip`, `createAsset`, `createTimelineState` +- Frame utilities: `toFrame`, `frameRate`, `framesToTimecode`, `framesToSeconds`, `secondsToFrames`, `FrameRates`, drop-frame support +- Atomic dispatcher with rolling-state validation (`dispatch`) +- 40+ operation primitives: `MOVE_CLIP`, `RESIZE_CLIP`, `SLICE_CLIP`, `DELETE_CLIP`, `INSERT_CLIP`, `SET_MEDIA_BOUNDS`, `SET_CLIP_ENABLED`, `SET_CLIP_SPEED`, `ADD_TRACK`, `DELETE_TRACK`, `REORDER_TRACK`, `REGISTER_ASSET`, `ADD_MARKER`, `MOVE_MARKER`, `DELETE_MARKER`, `SET_IN_POINT`, `SET_OUT_POINT`, `ADD_BEAT_GRID`, `INSERT_GENERATOR`, `ADD_CAPTION`, `EDIT_CAPTION`, `DELETE_CAPTION`, `ADD_EFFECT`, `REMOVE_EFFECT`, `ADD_KEYFRAME`, `MOVE_KEYFRAME`, `DELETE_KEYFRAME`, `SET_CLIP_TRANSFORM`, `SET_AUDIO_PROPERTIES`, `ADD_TRANSITION`, `DELETE_TRANSITION`, `LINK_CLIPS`, `UNLINK_CLIPS`, and more +- Invariant checker with 9 validation rules (`checkInvariants`) +- `HistoryStack` with undo/redo and configurable limit +- `TransactionCompressor` for merging rapid sequential edits +- Tool system: `ITool` interface, `ToolRegistry`, `ProvisionalManager` for drag previews +- 12 built-in tools: `SelectionTool`, `RazorTool`, `RippleTrimTool`, `RollTrimTool`, `SlipTool`, `SlideTool`, `RippleDeleteTool`, `RippleInsertTool`, `HandTool`, `TransitionTool`, `KeyframeTool`, `ZoomTool` +- Snap system: `SnapIndexManager`, `buildSnapIndex`, `nearest` +- `PlayheadController` with play/pause/seek and J/K/L shuttle +- `PlaybackEngine` with pipeline contracts (`VideoDecoder`, `AudioDecoder`, `Compositor`) +- `KeyboardHandler` with configurable key bindings +- Versioned JSON serialization: `serializeTimeline`, `deserializeTimeline` with automatic migration +- Export: `exportToOTIO`, `importFromOTIO`, `exportToEDL`, `exportToAAF`, `exportToFCPXML` +- SRT/VTT subtitle import: `parseSRT`, `parseVTT`, `subtitleImportToOps` +- Project model: `Project`, `Bin` with `addTimeline`, `addBin`, `serializeProject`, `deserializeProject` +- `IntervalTree` for O(log n) clip lookup +- `TrackIndex` for fast track-level queries +- `ThumbnailCache` (LRU) and `ThumbnailQueue` (priority) +- Virtual windowing: `getVisibleClips`, `getVisibleFrameRange` +- `diffStates` for efficient state change detection +- Effects, keyframes, easing curves, clip transforms, audio properties +- Transitions with alignment and duration controls +- Track groups and link groups +- Clock abstraction: `browserClock`, `nodeClock`, `createTestClock` +- 852 tests passing diff --git a/packages/core/README.md b/packages/core/README.md index d23d54f..2273506 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,117 +1,160 @@ -# @timeline/core +# @webpacked-timeline/core -Headless NLE timeline engine. Zero UI dependencies. -Runs anywhere TypeScript runs. +Headless TypeScript engine for professional NLE timeline editing. Framework-agnostic, fully tested, zero dependencies. ## Install + ```bash -npm install @timeline/core +npm install @webpacked-timeline/core ``` -## What's inside - -- **Dispatcher** — atomic transactions, - rolling-state validation, immutable state -- **Tools** — SelectionTool, RazorTool, - RippleTrimTool, RollTrimTool, SlipTool, - SlideTool, RippleDeleteTool, RippleInsertTool, - HandTool, TransitionTool, KeyframeTool, ZoomTool -- **Playback** — PlayheadController, PlaybackEngine, - pipeline contracts for decode + composite -- **Serialization** — JSON (versioned + migratable), - OTIO, EDL (CMX3600), AAF, FCP XML -- **Import** — SRT/VTT subtitle import -- **Project model** — multi-timeline container, - bin/folder hierarchy -- **Performance** — interval tree (O(log n) lookup), - transaction compression, LRU thumbnail cache, - virtual rendering contract - -## Basic usage +## Features + +- **40+ atomic operations** — `MOVE_CLIP`, `RESIZE_CLIP`, `SLICE_CLIP`, `INSERT_CLIP`, `DELETE_CLIP`, `SET_MEDIA_BOUNDS`, `ADD_TRACK`, `DELETE_TRACK`, `ADD_MARKER`, `ADD_EFFECT`, `ADD_KEYFRAME`, `ADD_TRANSITION`, `LINK_CLIPS`, and more +- **Tool system** — Selection, Razor, Ripple Trim, Roll Trim, Slip, Slide, Ripple Delete, Ripple Insert, Hand, Transition, Keyframe, Zoom +- **Undo/redo** with transaction compression +- **Playback engine** with J/K/L shuttle control via `KeyboardHandler` +- **Snap system** — `SnapIndexManager` with configurable snap points +- **Virtual windowing** — `getVisibleClips()` / `getVisibleFrameRange()` for large timelines +- **Export** — OTIO, EDL (CMX 3600), AAF, FCP XML +- **Serialization** — versioned JSON with `serializeTimeline` / `deserializeTimeline` +- **Import** — SRT and VTT subtitle import +- **Project model** — multi-timeline container with bin/folder hierarchy +- **Interval tree** — O(log n) clip lookup via `IntervalTree` / `TrackIndex` +- **Branded types** — `TimelineFrame`, `ClipId`, `TrackId`, `FrameRate` are distinct at compile time +- **Zero dependencies** + +## Quick Start + ```typescript import { createTimelineState, + createTimeline, createTrack, createClip, - toTrackId, - toClipId, - toFrame, dispatch, checkInvariants, -} from '@timeline/core' - -const track = createTrack(toTrackId('v1'), 'video') -const clip = createClip({ - id: toClipId('clip-1'), - trackId: toTrackId('v1'), - startFrame: toFrame(0), - durationFrames: 90, - mediaType: 'video', -}) + toFrame, + toTrackId, + toClipId, + toAssetId, + frameRate, +} from '@webpacked-timeline/core'; +// 1. Build initial state const state = createTimelineState({ - timeline: createTimeline({ fps: 30, durationFrames: 900 }), - tracks: [track], -}) - + timeline: createTimeline({ + id: 'tl-1', + name: 'My Timeline', + fps: frameRate(30), + duration: toFrame(9000), + tracks: [ + createTrack({ id: toTrackId('v1'), name: 'Video 1', type: 'video' }), + ], + }), +}); + +// 2. Dispatch an operation const result = dispatch(state, { - operations: [{ type: 'INSERT_CLIP', clip, trackId: track.id }], - label: 'Add clip', + id: 'tx-1', + label: 'Insert clip', timestamp: Date.now(), -}) - + operations: [{ + type: 'INSERT_CLIP', + trackId: toTrackId('v1'), + clip: createClip({ + id: toClipId('clip-1'), + assetId: toAssetId('asset-1'), + trackId: toTrackId('v1'), + timelineStart: toFrame(0), + timelineEnd: toFrame(90), + mediaIn: toFrame(0), + mediaOut: toFrame(90), + name: 'Intro', + }), + }], +}); + +// 3. Validate if (result.ok) { - const violations = checkInvariants(result.state) - console.log(violations) // [] + const violations = checkInvariants(result.state); + console.log(violations); // [] } ``` ## Playback + ```typescript -import { - PlaybackEngine, - browserClock, -} from '@timeline/core' +import { PlaybackEngine, browserClock } from '@webpacked-timeline/core'; -const engine = new PlaybackEngine( +const playback = new PlaybackEngine( state, - { videoDecoder, compositor }, + { videoDecoder, compositor }, // PipelineConfig { width: 1920, height: 1080 }, browserClock, -) +); -engine.play() -engine.on((event) => { - if (event.type === 'ended') console.log('done') -}) +playback.play(); ``` ## Serialization -```typescript -import { serializeTimeline, deserializeTimeline } - from '@timeline/core' -const json = serializeTimeline(state) -const state2 = deserializeTimeline(json) -// checkInvariants() runs automatically on deserialize +```typescript +import { + serializeTimeline, + deserializeTimeline, + exportToOTIO, + exportToEDL, + exportToAAF, + exportToFCPXML, +} from '@webpacked-timeline/core'; + +// JSON round-trip +const json = serializeTimeline(state); +const restored = deserializeTimeline(json); + +// Industry formats +const otio = exportToOTIO(state); +const edl = exportToEDL(state); +const aaf = exportToAAF(state); +const fcpxml = exportToFCPXML(state); ``` -## Architecture decisions +## Architecture + +- **Immutable state** — every operation returns a new object; unchanged clips keep their reference identity +- **Rolling-state validation** — each op in a compound transaction is validated against the result of the previous op +- **Branded types** — `TimelineFrame`, `ClipId`, `TrackId` are distinct types at compile time +- **No DOM, no React** — safe to use in Workers, Node.js, or Electron main process + +## API Reference + +### Factories +`createTimeline`, `createTrack`, `createClip`, `createAsset`, `createTimelineState` + +### Frame Utilities +`toFrame`, `frameRate`, `framesToTimecode`, `framesToSeconds`, `secondsToFrames`, `FrameRates` + +### State Management +`dispatch`, `checkInvariants`, `HistoryStack`, `TransactionCompressor` + +### Tools +`SelectionTool`, `RazorTool`, `RippleTrimTool`, `RollTrimTool`, `SlipTool`, `SlideTool`, `RippleDeleteTool`, `RippleInsertTool`, `HandTool`, `TransitionTool`, `KeyframeTool`, `ZoomTool` + +### Playback +`PlaybackEngine`, `PlayheadController`, `KeyboardHandler`, `browserClock`, `nodeClock` + +### Serialization & Export +`serializeTimeline`, `deserializeTimeline`, `exportToOTIO`, `importFromOTIO`, `exportToEDL`, `exportToAAF`, `exportToFCPXML` + +### Performance +`IntervalTree`, `TrackIndex`, `SnapIndexManager`, `ThumbnailCache`, `ThumbnailQueue`, `getVisibleClips` -- **Immutable state** — every operation returns - a new object. Unchanged clips keep their reference. -- **Branded types** — `TimelineFrame`, `ClipId`, - `TrackId` are distinct types at compile time. -- **Rolling-state validation** — each op in a - compound transaction is validated against the - result of the previous op, not the original state. -- **No DOM, no React** — safe to use in Workers, - Node, or Electron main process. +## Tests -## Test ```bash -pnpm --filter @timeline/core test -# 942 tests, 0 TypeScript errors +pnpm --filter @webpacked-timeline/core test +# 852 tests, 0 TypeScript errors ``` ## License diff --git a/packages/core/docs/ARCHITECTURE_HLD_LLD.md b/packages/core/docs/ARCHITECTURE_HLD_LLD.md index d58cf0d..79640b3 100644 --- a/packages/core/docs/ARCHITECTURE_HLD_LLD.md +++ b/packages/core/docs/ARCHITECTURE_HLD_LLD.md @@ -1,4 +1,4 @@ -# @timeline/core — Architecture (HLD & LLD) +# @webpacked-timeline/core — Architecture (HLD & LLD) High-Level Design (HLD) and Low-Level Design (LLD) for the timeline editing kernel. @@ -8,7 +8,7 @@ High-Level Design (HLD) and Low-Level Design (LLD) for the timeline editing kern ### 1.1 Purpose -`@timeline/core` is a **framework-agnostic**, **immutable** timeline editing kernel. It: +`@webpacked-timeline/core` is a **framework-agnostic**, **immutable** timeline editing kernel. It: - Holds the single source of truth: **TimelineState** (timeline + asset registry). - Exposes **one** mutation entry point: **dispatch(state, transaction)**. @@ -28,7 +28,7 @@ High-Level Design (HLD) and Low-Level Design (LLD) for the timeline editing kern ``` ┌─────────────────────────────────────────────────────────────────────────────┐ -│ @timeline/core │ +│ @webpacked-timeline/core │ ├─────────────────────────────────────────────────────────────────────────────┤ │ TYPES │ Canonical data: TimelineState, Clip, Track, Asset, │ │ │ OperationPrimitive, Transaction, TimelineFrame, IDs │ @@ -192,7 +192,7 @@ Exports: factories (createTimeline, createTrack, createClip, createAsset, create ### 3.1 Text Mind Map ``` -@timeline/core +@webpacked-timeline/core ├── RULES │ ├── Three-layer (core → no React/DOM) │ ├── Single mutation: dispatch(state, transaction) @@ -281,7 +281,7 @@ Exports: factories (createTimeline, createTrack, createClip, createAsset, create ```mermaid mindmap - root((@timeline/core)) + root((@webpacked-timeline/core)) RULES Three-layer Single mutation dispatch diff --git a/packages/core/docs/MINDMAP.md b/packages/core/docs/MINDMAP.md index 4a33e22..da01a12 100644 --- a/packages/core/docs/MINDMAP.md +++ b/packages/core/docs/MINDMAP.md @@ -1,4 +1,4 @@ -# @timeline/core — Architecture Mind Map +# @webpacked-timeline/core — Architecture Mind Map Standalone mind map and flow diagrams. View in any Mermaid-compatible viewer (GitHub, VS Code Mermaid extension, etc.). @@ -8,7 +8,7 @@ Standalone mind map and flow diagrams. View in any Mermaid-compatible viewer (Gi ```mermaid mindmap - root((@timeline/core)) + root((@webpacked-timeline/core)) RULES Three-layer core imports only stdlib + TS diff --git a/packages/core/package.json b/packages/core/package.json index 26a57ef..88797c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { - "name": "@timeline/core", - "version": "1.0.0", - "description": "Core library for timeline", + "name": "@webpacked-timeline/core", + "version": "1.0.0-beta.1", + "description": "Headless TypeScript engine for professional NLE timeline editing. Framework-agnostic, fully tested.", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -18,6 +18,7 @@ "require": "./dist/internal.cjs" } }, + "files": ["dist", "README.md", "CHANGELOG.md"], "scripts": { "build": "tsup src/index.ts src/internal.ts --format cjs,esm --dts", "dev": "tsup src/index.ts src/internal.ts --format cjs,esm --dts --watch", @@ -26,9 +27,15 @@ "test:coverage": "vitest run --coverage", "test:legacy": "tsx src/__tests__/edge-case-tests.ts && tsx src/__tests__/stress-tests.ts && tsx src/__tests__/phase2-tests.ts" }, - "keywords": [], + "keywords": ["timeline", "nle", "video-editor", "typescript", "headless", "editing"], "author": "", - "license": "ISC", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/manas-timeline/timeline", + "directory": "packages/core" + }, + "homepage": "https://github.com/manas-timeline/timeline/tree/main/packages/core#readme", "packageManager": "pnpm@10.28.2", "devDependencies": { "@types/node": "^24.10.11", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 671a3a2..9b4bce5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ /** - * @timeline/core + * @webpacked-timeline/core * * A deterministic, frame-based timeline editing kernel with professional editing intelligence. * @@ -12,7 +12,7 @@ * Internal systems are not exported and may change without notice. * * For internal access (tests, advanced integrations), use: - * import { ... } from '@timeline/core/internal' + * import { ... } from '@webpacked-timeline/core/internal' */ export * from './public-api'; diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index eb60488..8eeb701 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -1,5 +1,5 @@ /** - * @timeline/core - Internal API + * @webpacked-timeline/core - Internal API * * This file exports internal systems, operations, and utilities. * diff --git a/packages/core/src/public-api.ts b/packages/core/src/public-api.ts index 72c9051..cd8dfbe 100644 --- a/packages/core/src/public-api.ts +++ b/packages/core/src/public-api.ts @@ -1,5 +1,5 @@ /** - * @timeline/core — Public API (Phase 0) + * @webpacked-timeline/core — Public API (Phase 0) * * This is the stable contract surface. Everything here is intentional and minimal. * Internal files are not exported and may change without notice. @@ -57,6 +57,8 @@ export type { ClipId } from './types/clip'; export { toClipId } from './types/clip'; export type { TrackId } from './types/track'; export { toTrackId } from './types/track'; +export type { MarkerId } from './types/marker'; +export { toMarkerId } from './types/marker'; // Entity types export type { Asset, AssetStatus } from './types/asset'; @@ -178,6 +180,16 @@ export { toTrackGroupId, createTrackGroup } from './types/track-group'; export type { LinkGroupId, LinkGroup } from './types/link-group'; export { toLinkGroupId, createLinkGroup } from './types/link-group'; +// Phase 2 tools (default set for React TimelineEngine) +export { SelectionTool } from './tools/selection'; +export { RazorTool } from './tools/razor'; +export { RippleTrimTool } from './tools/ripple-trim'; +export { RollTrimTool } from './tools/roll-trim'; +export { SlipTool } from './tools/slip'; +export { RippleDeleteTool } from './tools/ripple-delete'; +export { RippleInsertTool } from './tools/ripple-insert'; +export { HandTool } from './tools/hand'; + // Phase 4 Step 4: Transition and Keyframe tools (register via createRegistry / registerTool) export { TransitionTool } from './tools/transition-tool'; export { KeyframeTool } from './tools/keyframe-tool'; diff --git a/packages/core/src/tools/hand.ts b/packages/core/src/tools/hand.ts index 3dd2cfd..fa6eeb3 100644 --- a/packages/core/src/tools/hand.ts +++ b/packages/core/src/tools/hand.ts @@ -20,7 +20,7 @@ * This allows testing drag tracking without a live callback. * * RULES: - * - Zero imports from React, DOM, @timeline/react, @timeline/ui + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui * - Every instance variable appears in onCancel() */ diff --git a/packages/core/src/tools/razor.ts b/packages/core/src/tools/razor.ts index 6d4459b..9319f4d 100644 --- a/packages/core/src/tools/razor.ts +++ b/packages/core/src/tools/razor.ts @@ -11,7 +11,7 @@ * - New ClipIds are generated by generateId() — replaceable in tests via _setIdGenerator() * * RULES: - * - Zero imports from React, DOM, @timeline/react, @timeline/ui + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui * - ctx.modifiers.shift is the authoritative source for shift detection (not event.shiftKey) * - Both halves need strictly positive duration — reject if atFrame is at clip boundary */ diff --git a/packages/core/src/tools/ripple-delete.ts b/packages/core/src/tools/ripple-delete.ts index d4a18f7..6bf9864 100644 --- a/packages/core/src/tools/ripple-delete.ts +++ b/packages/core/src/tools/ripple-delete.ts @@ -18,7 +18,7 @@ * because 'delete' is not a single-char tool-activation key. * * RULES: - * - Zero imports from React, DOM, @timeline/react, @timeline/ui + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui * - onPointerMove never dispatches (returns null always) * - onPointerUp never mutates instance state * - Every instance variable appears in onCancel() diff --git a/packages/core/src/tools/ripple-insert.ts b/packages/core/src/tools/ripple-insert.ts index f0c55fe..d44d6e8 100644 --- a/packages/core/src/tools/ripple-insert.ts +++ b/packages/core/src/tools/ripple-insert.ts @@ -19,7 +19,7 @@ * Ghost id is NEVER written to committed state — real clip gets new id at onPointerUp. * * RULES: - * - Zero imports from React, DOM, @timeline/react, @timeline/ui + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui * - onPointerMove never dispatches * - onPointerUp never mutates instance state (capture-before-reset) * - Every instance variable appears in onCancel() diff --git a/packages/core/src/tools/ripple-trim.ts b/packages/core/src/tools/ripple-trim.ts index ab0adbe..98c051b 100644 --- a/packages/core/src/tools/ripple-trim.ts +++ b/packages/core/src/tools/ripple-trim.ts @@ -23,7 +23,7 @@ * 3. Frame-0: for START trim, leftward shift must not push any left-clip below frame 0 * * RULES: - * - Zero imports from React, DOM, @timeline/react, @timeline/ui + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui * - onPointerMove never dispatches * - onPointerUp never mutates instance state * - Every instance variable appears in onCancel() diff --git a/packages/core/src/tools/roll-trim.ts b/packages/core/src/tools/roll-trim.ts index d35df86..7dab73c 100644 --- a/packages/core/src/tools/roll-trim.ts +++ b/packages/core/src/tools/roll-trim.ts @@ -18,7 +18,7 @@ * Read from ctx.state (committed, not yet changed) — avoids a 6th instance var. * * RULES: - * - Zero imports from React, DOM, @timeline/react, @timeline/ui + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui * - onPointerMove never dispatches * - onPointerUp never mutates instance state * - Every instance variable appears in onCancel() diff --git a/packages/core/src/tools/selection.ts b/packages/core/src/tools/selection.ts index f2b2c6e..465ca53 100644 --- a/packages/core/src/tools/selection.ts +++ b/packages/core/src/tools/selection.ts @@ -18,7 +18,7 @@ * originalPositions is ONLY used in onPointerUp for MOVE_CLIP delta math. * * RULES: - * - Zero imports from React, DOM, @timeline/react, @timeline/ui + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui * - onPointerMove must never call dispatch * - onPointerUp must never mutate instance state * - Every instance variable appears in onCancel() — no exceptions diff --git a/packages/core/src/tools/slip.ts b/packages/core/src/tools/slip.ts index 0e162ee..cb42f21 100644 --- a/packages/core/src/tools/slip.ts +++ b/packages/core/src/tools/slip.ts @@ -16,7 +16,7 @@ * SNAP: none. getSnapCandidateTypes returns []. * * RULES: - * - Zero imports from React, DOM, @timeline/react, @timeline/ui + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui * - onPointerMove never dispatches * - onPointerUp never mutates instance state * - Every instance variable appears in onCancel() diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts index 1c151e4..1d55506 100644 --- a/packages/core/src/tools/types.ts +++ b/packages/core/src/tools/types.ts @@ -47,7 +47,8 @@ export type Modifiers = { // --------------------------------------------------------------------------- /** Normalised pointer event in frame-space. - * ToolRouter populates clipId via hit-test — tools never recompute it. */ + * ToolRouter populates clipId/trackId via hit-test — tools never recompute it. + * Optional edge is set when click is within clip left/right hit zone (e.g. for trim). */ export type TimelinePointerEvent = { readonly frame: TimelineFrame; readonly trackId: TrackId | null; @@ -58,6 +59,8 @@ export type TimelinePointerEvent = { readonly shiftKey: boolean; readonly altKey: boolean; readonly metaKey: boolean; + /** When over a clip: 'left' | 'right' if within edge hit zone, else 'none'. */ + readonly edge?: 'left' | 'right' | 'none'; }; export type TimelineKeyEvent = { diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md new file mode 100644 index 0000000..6457e5e --- /dev/null +++ b/packages/react/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to `@webpacked-timeline/react` are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.0.0-beta.1] - 2026-03-07 + +### Added +- `TimelineEngine` orchestrator class — wires core's dispatcher, `HistoryStack`, `PlaybackEngine`, `SnapIndexManager`, `TrackIndex`, `KeyboardHandler`, and all 12 built-in tools +- `TimelineProvider` context + `TimelineContext` for React tree +- 20+ hooks with `useSyncExternalStore` for granular re-renders: + - `useEngine`, `useTimeline`, `useTrackIds`, `useTrack`, `useClip`, `useClips` + - `useMarkers`, `useHistory`, `useCanUndo`, `useCanRedo` + - `usePlayheadFrame`, `useIsPlaying`, `usePlaybackEngine`, `usePlayhead`, `usePlayheadEvent` + - `useActiveToolId`, `useActiveTool`, `useCursor` + - `useProvisional`, `useSelectedClipIds`, `useChange` +- Engine-first hook variants: `useTimelineWithEngine`, `useTrackIdsWithEngine`, `useTrackWithEngine`, `useClipWithEngine`, `useProvisionalWithEngine` +- `useVirtualWindow` and `useVisibleClips` for viewport-aware rendering +- `createToolRouter` adapter and `useToolRouter` hook for pointer/keyboard event wiring +- `EngineSnapshot` type for stable external store contract +- `DEFAULT_PLAYHEAD_STATE` constant +- 187 tests passing diff --git a/packages/react/README.md b/packages/react/README.md index d2e7761..72e8d49 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,73 +1,121 @@ -# React + TypeScript + Vite +# @webpacked-timeline/react -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React adapter for `@webpacked-timeline/core`. Provides `TimelineEngine`, hooks, context, and tool routing for building timeline editors. -Currently, two official plugins are available: +## Install -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +```bash +npm install @webpacked-timeline/core @webpacked-timeline/react +``` + +Both packages are required. `@webpacked-timeline/core` is a peer dependency. + +## Quick Start + +```tsx +import { TimelineEngine, TimelineProvider, useTrackIds, usePlayheadFrame } from '@webpacked-timeline/react'; +import { createTimelineState, createTimeline, toFrame, frameRate } from '@webpacked-timeline/core'; + +const engine = new TimelineEngine({ + initialState: createTimelineState({ + timeline: createTimeline({ + id: 'tl-1', + name: 'My Timeline', + fps: frameRate(30), + duration: toFrame(9000), + }), + }), +}); + +function App() { + return ( + + + + ); +} + +function TimelineView() { + const trackIds = useTrackIds(); + const frame = usePlayheadFrame(); + return ( +
+

Frame: {frame as number}

+

{trackIds.length} tracks

+
+ ); +} +``` -## React Compiler +## Hooks -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +All hooks accept an optional `engine` argument for use outside `TimelineProvider`. Inside the provider, they read from context automatically. -## Expanding the ESLint configuration +| Hook | Returns | Re-renders when | +|------|---------|-----------------| +| `useEngine()` | `TimelineEngine` | — (stable ref) | +| `useTimeline(engine?)` | `Timeline` | timeline metadata changes | +| `useTrackIds(engine?)` | `string[]` | tracks added/removed/reordered | +| `useTrack(engine?, trackId)` | `Track \| null` | that track changes | +| `useClip(engine?, clipId)` | `Clip \| null` | that clip changes | +| `useClips(engine?, trackId)` | `Clip[]` | any clip on track changes | +| `useMarkers(engine?)` | `Marker[]` | markers change | +| `useHistory(engine?)` | `{ canUndo, canRedo }` | history changes | +| `usePlayheadFrame(engine?)` | `TimelineFrame` | every frame tick | +| `useIsPlaying(engine?)` | `boolean` | play/pause toggle | +| `useActiveToolId(engine?)` | `string` | tool switch | +| `useActiveTool(engine?)` | `ITool` | tool switch | +| `useProvisional(engine?)` | `ProvisionalState \| null` | drag preview | +| `useSelectedClipIds(engine?)` | `ReadonlySet` | selection changes | +| `useCursor(engine?)` | `string` | cursor style changes | +| `useCanUndo(engine?)` | `boolean` | undo availability | +| `useCanRedo(engine?)` | `boolean` | redo availability | +| `useChange(engine?)` | `StateChange` | any state diff | +| `usePlaybackEngine(engine?)` | `PlaybackEngine \| null` | — | +| `usePlayhead(engine?)` | `UsePlayheadResult` | playhead state | +| `useVirtualWindow(engine, vpWidth, scrollLeft, ppf)` | `VirtualWindow` | viewport changes | +| `useVisibleClips(engine, window)` | `VirtualClipEntry[]` | visible clips change | -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +### Engine-first variants -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +For use without context: `useTimelineWithEngine(engine)`, `useTrackIdsWithEngine(engine)`, `useTrackWithEngine(engine, id)`, `useClipWithEngine(engine, id)`, `useProvisionalWithEngine(engine)`. - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +## Tool Routing - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```tsx +import { useToolRouter } from '@webpacked-timeline/react'; + +const handlers = useToolRouter(engine, { + getPixelsPerFrame: () => ppf, +}); + +return ( +
+ {/* timeline content */} +
+); ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +## TimelineEngine + +`TimelineEngine` is the main orchestrator class. It wires together the core dispatcher, history, tools, playback, snap system, and keyboard handler. + +```typescript +const engine = new TimelineEngine({ + initialState, // TimelineState (required) + pipeline, // PipelineConfig (optional) + clock, // Clock (optional, defaults to browserClock) + getPixelsPerFrame, // () => number (optional) + onZoomChange, // (ppf: number) => void (optional) + historyLimit, // number (optional, default 100) + compression, // CompressionPolicy (optional) + tools, // ITool[] (optional, overrides defaults) + defaultToolId, // string (optional, default 'selection') +}); ``` + +Key methods: `dispatch()`, `undo()`, `redo()`, `seekTo()`, `activateTool()`, `getState()`, `getSnapshot()`, `subscribe()`. + +## License + +MIT diff --git a/packages/react/examples/basic-usage.tsx b/packages/react/examples/basic-usage.tsx index 6a5f4c3..0ef14a1 100644 --- a/packages/react/examples/basic-usage.tsx +++ b/packages/react/examples/basic-usage.tsx @@ -1,5 +1,5 @@ /** - * @timeline/react - Basic Usage Example + * @webpacked-timeline/react - Basic Usage Example * * This example demonstrates how to use the React adapter with the timeline core. * @@ -21,14 +21,14 @@ import { createAsset, frame, frameRate, -} from '@timeline/core'; +} from '@webpacked-timeline/core'; import { TimelineProvider, useTimeline, useTrack, useClip, useEngine, -} from '@timeline/react'; +} from '@webpacked-timeline/react'; // Import internal utilities for example import { @@ -36,7 +36,7 @@ import { generateTrackId, generateClipId, generateAssetId, -} from '@timeline/core/internal'; +} from '@webpacked-timeline/core/internal'; // ===== CREATE ENGINE ===== diff --git a/packages/react/package.json b/packages/react/package.json index e5f47c8..ca7f799 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,7 +1,7 @@ { - "name": "@timeline/react", - "version": "0.1.0", - "description": "React adapter for @timeline/core - thin integration layer providing hooks and context", + "name": "@webpacked-timeline/react", + "version": "1.0.0-beta.1", + "description": "React adapter for @webpacked-timeline/core. Hooks, context, and tool routing for building timeline editors.", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -13,9 +13,7 @@ "require": "./dist/index.cjs" } }, - "files": [ - "dist" - ], + "files": ["dist", "README.md", "CHANGELOG.md"], "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", @@ -25,10 +23,11 @@ "test:coverage": "vitest run --coverage" }, "peerDependencies": { - "react": "^18.0.0" + "@webpacked-timeline/core": ">=1.0.0-beta.1", + "react": "^18.0.0 || ^19.0.0" }, "dependencies": { - "@timeline/core": "workspace:*" + "@webpacked-timeline/core": "workspace:*" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", @@ -43,13 +42,13 @@ "typescript": "^5.3.0", "vitest": "^2.1.8" }, - "keywords": [ - "timeline", - "react", - "hooks", - "video-editing", - "framework-adapter" - ], + "keywords": ["timeline", "react", "nle", "video-editor", "hooks", "typescript"], "author": "", - "license": "MIT" + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/manas-timeline/timeline", + "directory": "packages/react" + }, + "homepage": "https://github.com/manas-timeline/timeline/tree/main/packages/react#readme" } diff --git a/packages/react/src/App.tsx b/packages/react/src/App.tsx index f78c1f7..7b14074 100644 --- a/packages/react/src/App.tsx +++ b/packages/react/src/App.tsx @@ -1,79 +1,75 @@ /** * Minimal Timeline Example - * - * This is a simple example showing how to use the timeline packages. - * For a more complete demo, see apps/demo/ + * + * Uses Phase R TimelineEngine with options-based constructor. + * For a full demo with @webpacked-timeline/ui, see apps/demo/ */ import { - TimelineEngine, createTimeline, createTimelineState, createTrack, createClip, createAsset, - frame, + toFrame, frameRate, -} from '@timeline/core'; -import { TimelineProvider } from '@timeline/react'; -import { Timeline } from '@timeline/ui'; +} from '@webpacked-timeline/core'; +import { TimelineEngine } from './engine'; +import { TimelineProvider } from './index'; -// Import internal utilities for this example -import { - generateTimelineId, - generateTrackId, - generateClipId, - generateAssetId, -} from '@timeline/core/internal'; - -// Create a simple timeline with one track and one clip const timeline = createTimeline({ - id: generateTimelineId(), + id: 'tl-1', name: 'Example Timeline', fps: frameRate(30), - duration: frame(3000), // 100 seconds @ 30fps + duration: toFrame(3000), tracks: [], }); -const initialState = createTimelineState({ timeline }); -const engine = new TimelineEngine(initialState); - -// Add a video track const videoTrack = createTrack({ - id: generateTrackId(), - type: 'video', + id: 'track-v1', name: 'Video Track 1', + type: 'video', locked: false, clips: [], }); -engine.addTrack(videoTrack); - -// Add a sample video asset and clip const videoAsset = createAsset({ - id: generateAssetId(), - type: 'video', + id: 'asset-v1', name: 'Sample Video', - duration: frame(300), - sourceStart: frame(0), - sourceEnd: frame(300), - metadata: { width: 1920, height: 1080, frameRate: 30 }, + mediaType: 'video', + filePath: '/sample.mp4', + intrinsicDuration: toFrame(300), + nativeFps: frameRate(30), + sourceTimecodeOffset: toFrame(0), }); -engine.addAsset(videoAsset); - const videoClip = createClip({ - id: generateClipId(), + id: 'clip-1', assetId: videoAsset.id, - timelineStart: frame(0), - timelineEnd: frame(300), - sourceStart: frame(0), - sourceEnd: frame(300), - locked: false, + trackId: videoTrack.id, + timelineStart: toFrame(0), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(300), effects: [], }); -engine.addClip(videoTrack.id, videoClip); +const timelineWithTrack = createTimeline({ + ...timeline, + tracks: [ + createTrack({ + ...videoTrack, + clips: [videoClip], + }), + ], +}); + +const initialState = createTimelineState({ + timeline: timelineWithTrack, + assetRegistry: new Map([[videoAsset.id, videoAsset]]), +}); + +const engine = new TimelineEngine({ initialState }); function App() { return ( @@ -82,12 +78,10 @@ function App() {

Timeline Example

- A minimal example showing timeline integration + Phase R engine — for full UI use apps/demo with @webpacked-timeline/ui

-
- -
+
); diff --git a/packages/react/src/TimelineProvider.tsx b/packages/react/src/TimelineProvider.tsx index 0c2bee0..2c563ee 100644 --- a/packages/react/src/TimelineProvider.tsx +++ b/packages/react/src/TimelineProvider.tsx @@ -1,5 +1,5 @@ /** - * @timeline/react - TimelineProvider + * @webpacked-timeline/react - TimelineProvider * * React context provider for the Phase 1 TimelineEngine. * @@ -8,8 +8,8 @@ * * @example * ```tsx - * import { TimelineEngine } from '@timeline/react'; - * import { TimelineProvider } from '@timeline/react'; + * import { TimelineEngine } from '@webpacked-timeline/react'; + * import { TimelineProvider } from '@webpacked-timeline/react'; * * const engine = new TimelineEngine(initialState); * diff --git a/packages/react/src/__tests__/engine.test.ts b/packages/react/src/__tests__/engine.test.ts index 88d85eb..21ec27c 100644 --- a/packages/react/src/__tests__/engine.test.ts +++ b/packages/react/src/__tests__/engine.test.ts @@ -20,11 +20,13 @@ import { TimelineEngine, type EngineSnapshot } from '../engine'; import { createTimelineState, createTimeline, + createTrack, toFrame, toTimecode, NoOpTool, toToolId, -} from '@timeline/core'; + createTestClock, +} from '@webpacked-timeline/core'; import type { ITool, ToolContext, @@ -34,7 +36,7 @@ import type { ProvisionalState, Transaction, TimelineState, -} from '@timeline/core'; +} from '@webpacked-timeline/core'; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -68,7 +70,14 @@ function makePointerEvent(frame = 0): TimelinePointerEvent { } function makeKeyEvent(key = 'x'): TimelineKeyEvent { - return { key, code: `Key${key.toUpperCase()}`, shiftKey: false, altKey: false, metaKey: false }; + return { + key, + code: key === ' ' ? 'Space' : `Key${key.toUpperCase()}`, + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + }; } /** Minimal valid transaction — rename the timeline. */ @@ -105,9 +114,12 @@ function makeTool(id: string, overrides: Partial): ITool { function makeEngine() { const state = makeState(); - // Two tools: NoOpTool (default) + a placeholder "alt" tool const altTool = makeTool('alt', {}); - return new TimelineEngine(state, [NoOpTool, altTool], toToolId('noop')); + return new TimelineEngine({ + initialState: state, + tools: [NoOpTool, altTool], + defaultToolId: 'noop', + }); } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -196,9 +208,10 @@ describe('undo() / redo()', () => { it('undo() is a no-op when canUndo is false', () => { const engine = makeEngine(); - const snap = engine.getSnapshot(); - engine.undo(); // nothing to undo - expect(engine.getSnapshot()).toBe(snap); // same reference + const nameBefore = engine.getSnapshot().state.timeline.name; + const undone = engine.undo(); + expect(undone).toBe(false); + expect(engine.getSnapshot().state.timeline.name).toBe(nameBefore); }); }); @@ -208,7 +221,11 @@ describe('provisional state', () => { const moveTool = makeTool('move', { onPointerMove: () => ghost, }); - const engine = new TimelineEngine(makeState(), [NoOpTool, moveTool], toToolId('move')); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, moveTool], + defaultToolId: 'move', + }); engine.handlePointerMove(makePointerEvent(), noModifiers); expect(engine.getSnapshot().provisional).toBe(ghost); }); @@ -219,7 +236,11 @@ describe('provisional state', () => { const moveTool = makeTool('move', { onPointerMove: () => callCount++ === 0 ? ghost : null, }); - const engine = new TimelineEngine(makeState(), [NoOpTool, moveTool], toToolId('move')); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, moveTool], + defaultToolId: 'move', + }); engine.handlePointerMove(makePointerEvent(), noModifiers); // sets provisional engine.handlePointerMove(makePointerEvent(), noModifiers); // clears provisional expect(engine.getSnapshot().provisional).toBeNull(); @@ -231,7 +252,11 @@ describe('provisional state', () => { onPointerMove: () => ghost, onPointerUp: () => makeRenameTx('committed'), }); - const engine = new TimelineEngine(makeState(), [NoOpTool, upTool], toToolId('up')); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, upTool], + defaultToolId: 'up', + }); engine.handlePointerMove(makePointerEvent(), noModifiers); // sets provisional engine.handlePointerUp(makePointerEvent(), noModifiers); // clears, dispatches expect(engine.getSnapshot().provisional).toBeNull(); @@ -244,7 +269,11 @@ describe('provisional state', () => { onPointerMove: () => ghost, onPointerUp: () => null, // no commit }); - const engine = new TimelineEngine(makeState(), [NoOpTool, upTool], toToolId('up')); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, upTool], + defaultToolId: 'up', + }); engine.handlePointerMove(makePointerEvent(), noModifiers); engine.handlePointerUp(makePointerEvent(), noModifiers); expect(engine.getSnapshot().provisional).toBeNull(); @@ -254,29 +283,27 @@ describe('provisional state', () => { describe('activateTool()', () => { it('snapshot.activeToolId changes after activateTool()', () => { const engine = makeEngine(); - expect(engine.getSnapshot().activeToolId).toBe(toToolId('noop')); - engine.activateTool(toToolId('alt')); - expect(engine.getSnapshot().activeToolId).toBe(toToolId('alt')); + expect(engine.getSnapshot().activeToolId).toBe('noop'); + engine.activateTool('alt'); + expect(engine.getSnapshot().activeToolId).toBe('alt'); }); it('activateTool() calls onCancel() on the outgoing tool', () => { const cancelSpy = vi.fn(); const cancelTool = makeTool('cancel', { onCancel: cancelSpy }); - const engine = new TimelineEngine( - makeState(), [cancelTool, NoOpTool], toToolId('cancel'), - ); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [cancelTool, NoOpTool], + defaultToolId: 'cancel', + }); engine.activateTool(toToolId('noop')); expect(cancelSpy).toHaveBeenCalledOnce(); }); it('activateTool() throws on unknown id — snapshot unchanged', () => { const engine = makeEngine(); - const snapBefore = engine.getSnapshot(); - expect(() => engine.activateTool(toToolId('ghost'))).toThrow(); - // snapshot must not have changed (activateTool throws, so engine doesn't rebuild) - // We can't check same ref since activateTool rebuilds before throwing (registry throws) - // but the activeToolId must still be noop - expect(engine.getSnapshot().activeToolId).toBe(toToolId('noop')); + expect(() => engine.activateTool('ghost')).toThrow(); + expect(engine.getSnapshot().activeToolId).toBe('noop'); }); }); @@ -292,7 +319,11 @@ describe('handlePointerDown', () => { it('delegates to getActiveTool().onPointerDown', () => { const downSpy = vi.fn(); const downTool = makeTool('down', { onPointerDown: downSpy }); - const engine = new TimelineEngine(makeState(), [NoOpTool, downTool], toToolId('down')); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, downTool], + defaultToolId: 'down', + }); const event = makePointerEvent(10); engine.handlePointerDown(event, noModifiers); expect(downSpy).toHaveBeenCalledOnce(); @@ -313,7 +344,11 @@ describe('handleKeyDown', () => { const keyTool = makeTool('keytool', { onKeyDown: (_e: TimelineKeyEvent, _ctx: ToolContext) => makeRenameTx('from-key'), }); - const engine = new TimelineEngine(makeState(), [NoOpTool, keyTool], toToolId('keytool')); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, keyTool], + defaultToolId: 'keytool', + }); const listener = vi.fn(); engine.subscribe(listener); @@ -323,30 +358,14 @@ describe('handleKeyDown', () => { }); }); -describe('setPixelsPerFrame', () => { - it('does NOT trigger notify — ppf is not in EngineSnapshot', () => { - const engine = makeEngine(); - const listener = vi.fn(); - engine.subscribe(listener); - engine.setPixelsPerFrame(20); - expect(listener).not.toHaveBeenCalled(); - }); -}); - -describe('setPlayheadFrame', () => { - it('triggers notify — playhead changes are visible state', () => { - const engine = makeEngine(); - const listener = vi.fn(); - engine.subscribe(listener); - engine.setPlayheadFrame(toFrame(100)); - expect(listener).toHaveBeenCalledOnce(); - }); -}); - describe('NoOpTool default', () => { - it('engine with no tools arg defaults to NoOpTool with activeToolId "noop"', () => { - const engine = new TimelineEngine(makeState()); - expect(engine.getSnapshot().activeToolId).toBe(toToolId('noop')); + it('engine with defaultToolId "noop" has activeToolId "noop"', () => { + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool], + defaultToolId: 'noop', + }); + expect(engine.getSnapshot().activeToolId).toBe('noop'); // Confirm all pointer/key methods run without throwing expect(() => { engine.handlePointerDown(makePointerEvent(), noModifiers); @@ -363,7 +382,11 @@ describe('notify() storm guard', () => { const moveTool = makeTool('move', { onPointerMove: () => makeProvisional(), }); - const engine = new TimelineEngine(makeState(), [NoOpTool, moveTool], toToolId('move')); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, moveTool], + defaultToolId: 'move', + }); const listener = vi.fn(); engine.subscribe(listener); engine.handlePointerMove(makePointerEvent(), noModifiers); @@ -375,9 +398,234 @@ describe('notify() storm guard', () => { const moveTool = makeTool('movetool', { onPointerMove: () => ghost, }); - const engine = new TimelineEngine(makeState(), [NoOpTool, moveTool], toToolId('movetool')); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, moveTool], + defaultToolId: 'movetool', + }); engine.handlePointerMove(makePointerEvent(), noModifiers); // toBe not toEqual — confirms no unnecessary copying expect(engine.getSnapshot().provisional).toBe(ghost); }); }); + +// ── Phase R Step 1 — Full orchestrator (23 new tests) ──────────────────────── + +describe('Phase R Step 1 — Construction', () => { + it('1. Engine constructs without pipeline (edit-only)', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + expect(engine.playbackEngine).toBeNull(); + expect(engine.getSnapshot().playhead.isPlaying).toBe(false); + }); + + it('2. Engine constructs with pipeline', () => { + const mockPipeline = { + videoDecoder: vi.fn().mockResolvedValue({}), + compositor: vi.fn().mockResolvedValue({}), + } as any; + const { clock } = createTestClock(); + const engine = new TimelineEngine({ + initialState: makeState(), + pipeline: mockPipeline, + clock, + }); + expect(engine.playbackEngine).not.toBeNull(); + }); + + it('3. getSnapshot() returns valid EngineSnapshot', () => { + const engine = makeEngine(); + const snap = engine.getSnapshot(); + expect(snap).toHaveProperty('state'); + expect(snap).toHaveProperty('provisional'); + expect(snap).toHaveProperty('activeToolId'); + expect(snap).toHaveProperty('canUndo'); + expect(snap).toHaveProperty('canRedo'); + expect(snap).toHaveProperty('trackIds'); + expect(snap).toHaveProperty('cursor'); + expect(snap).toHaveProperty('playhead'); + expect(snap).toHaveProperty('change'); + }); + + it('4. Initial snapshot has correct trackIds', () => { + const state = makeState(); + const engine = new TimelineEngine({ initialState: state }); + const expected = state.timeline.tracks.map((t) => t.id); + expect(engine.getSnapshot().trackIds).toEqual(expected); + }); + + it('5. Initial canUndo: false, canRedo: false', () => { + const engine = makeEngine(); + expect(engine.getSnapshot().canUndo).toBe(false); + expect(engine.getSnapshot().canRedo).toBe(false); + }); +}); + +describe('Phase R Step 1 — dispatch', () => { + it('6. dispatch updates snapshot.state', () => { + const engine = makeEngine(); + engine.dispatch(makeRenameTx('NewName')); + expect(engine.getSnapshot().state.timeline.name).toBe('NewName'); + }); + + it('7. dispatch updates canUndo: true', () => { + const engine = makeEngine(); + engine.dispatch(makeRenameTx('X')); + expect(engine.getSnapshot().canUndo).toBe(true); + }); + + it('8. dispatch notifies subscribers', () => { + const engine = makeEngine(); + const listener = vi.fn(); + engine.subscribe(listener); + engine.dispatch(makeRenameTx('Y')); + expect(listener).toHaveBeenCalled(); + }); + + it('9. Failed dispatch does not notify subscribers', () => { + const engine = makeEngine(); + const listener = vi.fn(); + engine.subscribe(listener); + engine.dispatch(makeRejectTx()); + expect(listener).not.toHaveBeenCalled(); + }); + + it('10. dispatch updates stableTrackIds when track list changes', () => { + const state = makeState(); + const engine = new TimelineEngine({ initialState: state }); + const idsBefore = engine.getSnapshot().trackIds; + const newTrack = createTrack({ + id: 'track-new', + name: 'New', + type: 'video', + clips: [], + }); + engine.dispatch({ + id: 'add', + label: 'Add track', + timestamp: 0, + operations: [{ type: 'ADD_TRACK', track: newTrack }], + }); + const idsAfter = engine.getSnapshot().trackIds; + expect(idsAfter.length).toBe(idsBefore.length + 1); + expect(idsAfter).toContain('track-new'); + }); +}); + +describe('Phase R Step 1 — undo/redo', () => { + it('11. undo() restores previous state', () => { + const engine = makeEngine(); + const nameBefore = engine.getSnapshot().state.timeline.name; + engine.dispatch(makeRenameTx('After')); + engine.undo(); + expect(engine.getSnapshot().state.timeline.name).toBe(nameBefore); + }); + + it('12. undo() updates canUndo/canRedo flags', () => { + const engine = makeEngine(); + engine.dispatch(makeRenameTx('A')); + expect(engine.getSnapshot().canUndo).toBe(true); + expect(engine.getSnapshot().canRedo).toBe(false); + engine.undo(); + expect(engine.getSnapshot().canUndo).toBe(false); + expect(engine.getSnapshot().canRedo).toBe(true); + }); + + it('13. redo() after undo restores forward state', () => { + const engine = makeEngine(); + engine.dispatch(makeRenameTx('Redone')); + engine.undo(); + engine.redo(); + expect(engine.getSnapshot().state.timeline.name).toBe('Redone'); + }); + + it('14. undo() returns false when nothing to undo', () => { + const engine = makeEngine(); + expect(engine.undo()).toBe(false); + }); +}); + +describe('Phase R Step 1 — Tools', () => { + it('15. activateTool changes activeToolId in snapshot', () => { + const engine = makeEngine(); + engine.activateTool('alt'); + expect(engine.getSnapshot().activeToolId).toBe('alt'); + }); + + it('16. getActiveToolId returns correct id', () => { + const engine = makeEngine(); + expect(engine.getActiveToolId()).toBe('noop'); + engine.activateTool('alt'); + expect(engine.getActiveToolId()).toBe('alt'); + }); + + it('17. handleKeyDown returns true for Space key when playback engine present', () => { + const mockPipeline = { videoDecoder: vi.fn(), compositor: vi.fn() } as any; + const { clock } = createTestClock(); + const engine = new TimelineEngine({ + initialState: makeState(), + pipeline: mockPipeline, + clock, + }); + const result = engine.handleKeyDown( + { key: ' ', code: 'Space', shiftKey: false, altKey: false, metaKey: false, ctrlKey: false }, + noModifiers, + ); + expect(result).toBe(true); + }); +}); + +describe('Phase R Step 1 — Snapshot stability and subscribers', () => { + it('18. dispatch calls subscriber exactly once per dispatch', () => { + const engine = makeEngine(); + const listener = vi.fn(); + engine.subscribe(listener); + engine.dispatch(makeRenameTx('Once')); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('19. subscribe returns unsubscribe function', () => { + const engine = makeEngine(); + const unsub = engine.subscribe(vi.fn()); + expect(typeof unsub).toBe('function'); + unsub(); + }); + + it('20. After unsubscribe, callback not called', () => { + const engine = makeEngine(); + const listener = vi.fn(); + const unsub = engine.subscribe(listener); + unsub(); + engine.dispatch(makeRenameTx('NoNotify')); + expect(listener).not.toHaveBeenCalled(); + }); + + it('21. Multiple subscribers all notified', () => { + const engine = makeEngine(); + const a = vi.fn(); + const b = vi.fn(); + engine.subscribe(a); + engine.subscribe(b); + engine.dispatch(makeRenameTx('Both')); + expect(a).toHaveBeenCalled(); + expect(b).toHaveBeenCalled(); + }); +}); + +describe('Phase R Step 1 — Playback integration', () => { + it('22. playbackEngine is null without pipeline', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + expect(engine.playbackEngine).toBeNull(); + }); + + it('23. playbackEngine is PlaybackEngine with pipeline', () => { + const mockPipeline = { videoDecoder: vi.fn(), compositor: vi.fn() } as any; + const { clock } = createTestClock(); + const engine = new TimelineEngine({ + initialState: makeState(), + pipeline: mockPipeline, + clock, + }); + expect(engine.playbackEngine).not.toBeNull(); + expect(typeof (engine.playbackEngine as any)?.getState).toBe('function'); + }); +}); diff --git a/packages/react/src/__tests__/hooks-r2.test.tsx b/packages/react/src/__tests__/hooks-r2.test.tsx new file mode 100644 index 0000000..9119fda --- /dev/null +++ b/packages/react/src/__tests__/hooks-r2.test.tsx @@ -0,0 +1,571 @@ +/** + * Phase R Step 2 — Full hook set tests + * + * Fixture: engine with 2 tracks, 3 clips total, 2 markers, 30fps. + * All hooks take engine as first arg. Selector isolation proven with toBe. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + createTimeline, + createTimelineState, + createTrack, + createClip, + createAsset, + toFrame, + toTimecode, + frameRate, + toTrackId, + toClipId, + toAssetId, + toToolId, + createTestClock, + NoOpTool, +} from '@webpacked-timeline/core'; +import type { Transaction } from '@webpacked-timeline/core'; + +import { TimelineEngine } from '../engine'; +import { + useTimeline, + useTrackIds, + useTrack, + useClip, + useClips, + useMarkers, + useHistory, + useActiveToolId, + useCursor, + useProvisional, + usePlayheadFrame, + useIsPlaying, + useChange, +} from '../hooks/index'; + +// ── Fixture: 2 tracks, 3 clips, 2 markers, 30fps ───────────────────────────── + +const TRACK_1 = toTrackId('track-1'); +const TRACK_2 = toTrackId('track-2'); +const CLIP_A = toClipId('clip-a'); +const CLIP_B = toClipId('clip-b'); +const CLIP_C = toClipId('clip-c'); + +function makeFixtureState() { + const asset = createAsset({ + id: 'asset-1', + name: 'Asset', + mediaType: 'video', + filePath: '/a.mp4', + intrinsicDuration: toFrame(600), + nativeFps: frameRate(30), + sourceTimecodeOffset: toFrame(0), + }); + + const clipA = createClip({ + id: 'clip-a', + assetId: asset.id, + trackId: TRACK_1, + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clipB = createClip({ + id: 'clip-b', + assetId: asset.id, + trackId: TRACK_1, + timelineStart: toFrame(150), + timelineEnd: toFrame(250), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clipC = createClip({ + id: 'clip-c', + assetId: asset.id, + trackId: TRACK_2, + timelineStart: toFrame(50), + timelineEnd: toFrame(150), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + + const track1 = createTrack({ + id: TRACK_1, + name: 'V1', + type: 'video', + clips: [clipA, clipB], + }); + const track2 = createTrack({ + id: TRACK_2, + name: 'V2', + type: 'video', + clips: [clipC], + }); + + const marker1 = { + type: 'point' as const, + id: 'm1' as import('@webpacked-timeline/core').MarkerId, + frame: toFrame(50), + label: 'M1', + color: '#ff0000', + scope: 'global' as const, + linkedClipId: null, + }; + const marker2 = { + type: 'point' as const, + id: 'm2' as import('@webpacked-timeline/core').MarkerId, + frame: toFrame(200), + label: 'M2', + color: '#00ff00', + scope: 'global' as const, + linkedClipId: null, + }; + + const timeline = createTimeline({ + id: 'tl-r2', + name: 'R2 Fixture', + fps: frameRate(30), + duration: toFrame(3000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track1, track2], + markers: [marker1, marker2], + }); + + return createTimelineState({ + timeline, + assetRegistry: new Map([[asset.id, asset]]), + }); +} + +let engine: TimelineEngine; + +beforeEach(() => { + const state = makeFixtureState(); + engine = new TimelineEngine({ + initialState: state, + tools: [NoOpTool], + defaultToolId: 'noop', + }); +}); + +// ── useTimeline ───────────────────────────────────────────────────────────── + +describe('useTimeline', () => { + it('1. Returns correct fps and durationFrames', () => { + const { result } = renderHook(() => useTimeline(engine)); + expect(result.current.fps).toBe(30); + expect(result.current.duration).toBe(3000); + }); + + it('2. Re-renders when timeline changes', () => { + const { result, rerender } = renderHook(() => useTimeline(engine)); + const first = result.current; + act(() => { + engine.dispatch({ + id: 'r', + label: 'Rename', + timestamp: 0, + operations: [{ type: 'RENAME_TIMELINE', name: 'Renamed' }], + }); + }); + rerender(); + expect(result.current.name).toBe('Renamed'); + expect(result.current).not.toBe(first); + }); + + it('3. Does NOT re-render when only playhead changes (timeline ref unchanged)', () => { + const mockPipeline = { videoDecoder: vi.fn(), compositor: vi.fn() } as any; + const { clock } = createTestClock(); + const engWithPlayback = new TimelineEngine({ + initialState: makeFixtureState(), + pipeline: mockPipeline, + clock, + tools: [NoOpTool], + defaultToolId: 'noop', + }); + const { result, rerender } = renderHook(() => useTimeline(engWithPlayback)); + const refBefore = result.current; + act(() => engWithPlayback.playbackEngine!.seekTo(toFrame(100))); + rerender(); + expect(result.current).toBe(refBefore); + }); +}); + +// ── useTrackIds ──────────────────────────────────────────────────────────── + +describe('useTrackIds', () => { + it('4. Returns correct track ids', () => { + const { result } = renderHook(() => useTrackIds(engine)); + expect(result.current).toEqual(['track-1', 'track-2']); + }); + + it('5. Returns stable reference when clips change (trackIds ref unchanged after MOVE_CLIP)', () => { + const { result, rerender } = renderHook(() => useTrackIds(engine)); + const refBefore = result.current; + act(() => { + engine.dispatch({ + id: 'm', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: CLIP_A, newTimelineStart: toFrame(20) }, + ], + }); + }); + rerender(); + expect(result.current).toBe(refBefore); + }); + + it('6. Returns new reference when track added', () => { + const { result, rerender } = renderHook(() => useTrackIds(engine)); + const refBefore = result.current; + const newTrack = createTrack({ + id: toTrackId('track-3'), + name: 'V3', + type: 'video', + clips: [], + }); + act(() => { + engine.dispatch({ + id: 'a', + label: 'Add', + timestamp: 0, + operations: [{ type: 'ADD_TRACK', track: newTrack }], + }); + }); + rerender(); + expect(result.current).not.toBe(refBefore); + expect(result.current).toContain('track-3'); + }); +}); + +// ── useTrack ──────────────────────────────────────────────────────────────── + +describe('useTrack', () => { + it('7. Returns correct track for id', () => { + const { result } = renderHook(() => useTrack(engine, TRACK_1)); + expect(result.current?.id).toBe('track-1'); + expect(result.current?.name).toBe('V1'); + }); + + it('8. Returns null for unknown id', () => { + const { result } = renderHook(() => useTrack(engine, 'unknown')); + expect(result.current).toBeNull(); + }); + + it('9. Re-renders when that track changes', () => { + const { result, rerender } = renderHook(() => useTrack(engine, TRACK_1)); + act(() => { + engine.dispatch({ + id: 'n', + label: 'Name', + timestamp: 0, + operations: [{ type: 'SET_TRACK_NAME', trackId: TRACK_1, name: 'Video One' }], + }); + }); + rerender(); + expect(result.current?.name).toBe('Video One'); + }); + + it('10. Does NOT re-render when OTHER track changes', () => { + const { result, rerender } = renderHook(() => useTrack(engine, TRACK_1)); + const refBefore = result.current; + act(() => { + engine.dispatch({ + id: 'n', + label: 'Name', + timestamp: 0, + operations: [{ type: 'SET_TRACK_NAME', trackId: TRACK_2, name: 'Other' }], + }); + }); + rerender(); + expect(result.current).toBe(refBefore); + }); +}); + +// ── useClip (isolation: clip A update does not re-render clip B) ───────────── + +describe('useClip', () => { + it('11. Returns correct clip for id', () => { + const { result } = renderHook(() => useClip(engine, CLIP_A)); + expect(result.current?.id).toBe('clip-a'); + expect(result.current?.timelineStart).toBe(0); + }); + + it('12. Returns null for unknown id', () => { + const { result } = renderHook(() => useClip(engine, 'no-such-clip' as any)); + expect(result.current).toBeNull(); + }); + + it('13. ISOLATION: updating clip A does not re-render component watching clip B (toBe)', () => { + let renderCountA = 0; + let renderCountB = 0; + renderHook(() => { + renderCountA++; + return useClip(engine, CLIP_A); + }); + const { result: resultB } = renderHook(() => { + renderCountB++; + return useClip(engine, CLIP_B); + }); + const clipBRefBefore = resultB.current; + renderCountA = 0; + renderCountB = 0; + act(() => { + engine.dispatch({ + id: 'm', + label: 'Move A', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: CLIP_A, newTimelineStart: toFrame(5) }, + ], + }); + }); + expect(renderCountB).toBe(0); + expect(resultB.current).toBe(clipBRefBefore); + }); +}); + +// ── useClips ─────────────────────────────────────────────────────────────── + +describe('useClips', () => { + it('14. Returns all clips for a track', () => { + const { result } = renderHook(() => useClips(engine, TRACK_1)); + expect(result.current.length).toBe(2); + expect(result.current.map((c) => c.id)).toEqual(['clip-a', 'clip-b']); + }); + + it('15. Returns EMPTY_CLIPS stable ref for unknown trackId', () => { + const { result } = renderHook(() => useClips(engine, 'unknown-track')); + expect(result.current).toEqual([]); + const { result: result2 } = renderHook(() => useClips(engine, 'unknown-track')); + expect(result2.current).toBe(result.current); + }); +}); + +// ── useMarkers ───────────────────────────────────────────────────────────── + +describe('useMarkers', () => { + it('16. Returns markers array', () => { + const { result } = renderHook(() => useMarkers(engine)); + expect(result.current.length).toBe(2); + expect(result.current[0]?.label).toBe('M1'); + }); + + it('17. Updates when marker added', () => { + const { result, rerender } = renderHook(() => useMarkers(engine)); + expect(result.current.length).toBe(2); + act(() => { + engine.dispatch({ + id: 'am', + label: 'Add marker', + timestamp: 0, + operations: [ + { + type: 'ADD_MARKER', + marker: { + type: 'point', + id: 'm3' as any, + frame: toFrame(100), + label: 'M3', + color: '#0000ff', + scope: 'global', + linkedClipId: null, + }, + }, + ], + }); + }); + rerender(); + expect(result.current.length).toBe(3); + }); +}); + +// ── useHistory ───────────────────────────────────────────────────────────── + +describe('useHistory', () => { + it('18. Initial: canUndo false, canRedo false', () => { + const { result } = renderHook(() => useHistory(engine)); + expect(result.current.canUndo).toBe(false); + expect(result.current.canRedo).toBe(false); + }); + + it('19. After dispatch: canUndo true', () => { + const { result, rerender } = renderHook(() => useHistory(engine)); + act(() => { + engine.dispatch({ + id: 'r', + label: 'R', + timestamp: 0, + operations: [{ type: 'RENAME_TIMELINE', name: 'X' }], + }); + }); + rerender(); + expect(result.current.canUndo).toBe(true); + }); + + it('20. Returns stable object ref when flags unchanged (no spurious re-render)', () => { + const { result, rerender } = renderHook(() => useHistory(engine)); + const refBefore = result.current; + act(() => { + engine.dispatch({ + id: 'r', + label: 'R', + timestamp: 0, + operations: [{ type: 'RENAME_TIMELINE', name: 'Y' }], + }); + }); + rerender(); + const refAfter = result.current; + act(() => engine.undo()); + rerender(); + const refAfterUndo = result.current; + expect(refAfter).not.toBe(refBefore); + expect(refAfterUndo).not.toBe(refAfter); + }); +}); + +// ── useActiveToolId ──────────────────────────────────────────────────────── + +describe('useActiveToolId', () => { + it('21. Returns "noop" initially (defaultToolId in fixture)', () => { + const { result } = renderHook(() => useActiveToolId(engine)); + expect(result.current).toBe('noop'); + }); + + it('22. Updates after activateTool()', () => { + const { result, rerender } = renderHook(() => useActiveToolId(engine)); + act(() => engine.activateTool('selection')); + rerender(); + expect(result.current).toBe('selection'); + }); +}); + +// ── useCursor ────────────────────────────────────────────────────────────── + +describe('useCursor', () => { + it('23. Returns "default" initially', () => { + const { result } = renderHook(() => useCursor(engine)); + expect(result.current).toBe('default'); + }); +}); + +// ── useProvisional ───────────────────────────────────────────────────────── + +describe('useProvisional', () => { + it('24. Returns null initially', () => { + const { result } = renderHook(() => useProvisional(engine)); + expect(result.current).toBeNull(); + }); + + it('25. Returns provisional state when set', () => { + const ghost = { clips: [], isProvisional: true as const }; + const moveTool = { + ...NoOpTool, + id: toToolId('provisional'), + onPointerMove: () => ghost, + }; + const engWithProvisional = new TimelineEngine({ + initialState: makeFixtureState(), + tools: [NoOpTool, moveTool], + defaultToolId: 'provisional', + }); + const { result, rerender } = renderHook(() => useProvisional(engWithProvisional)); + expect(result.current).toBeNull(); + act(() => { + engWithProvisional.handlePointerMove( + { + frame: toFrame(0), + trackId: null, + clipId: null, + x: 0, + y: 0, + buttons: 1, + shiftKey: false, + altKey: false, + metaKey: false, + }, + { shift: false, alt: false, ctrl: false, meta: false }, + ); + }); + rerender(); + expect(result.current).toBe(ghost); + }); +}); + +// ── usePlayheadFrame ─────────────────────────────────────────────────────── + +describe('usePlayheadFrame', () => { + it('26. Returns frame 0 initially', () => { + const { result } = renderHook(() => usePlayheadFrame(engine)); + expect(result.current).toBe(0); + }); + + it('27. Updates when seekTo called on playback engine', () => { + const mockPipeline = { videoDecoder: vi.fn(), compositor: vi.fn() } as any; + const { clock } = createTestClock(); + const engWithPlayback = new TimelineEngine({ + initialState: makeFixtureState(), + pipeline: mockPipeline, + clock, + tools: [NoOpTool], + defaultToolId: 'noop', + }); + const { result, rerender } = renderHook(() => usePlayheadFrame(engWithPlayback)); + expect(result.current).toBe(0); + act(() => engWithPlayback.playbackEngine!.seekTo(toFrame(100))); + rerender(); + expect(result.current).toBe(100); + }); +}); + +// ── useIsPlaying ─────────────────────────────────────────────────────────── + +describe('useIsPlaying', () => { + it('28. Returns false initially', () => { + const { result } = renderHook(() => useIsPlaying(engine)); + expect(result.current).toBe(false); + }); + + it('29. Returns true after play()', () => { + const mockPipeline = { videoDecoder: vi.fn(), compositor: vi.fn() } as any; + const { clock } = createTestClock(); + const engWithPlayback = new TimelineEngine({ + initialState: makeFixtureState(), + pipeline: mockPipeline, + clock, + tools: [NoOpTool], + defaultToolId: 'noop', + }); + const { result, rerender } = renderHook(() => useIsPlaying(engWithPlayback)); + act(() => engWithPlayback.playbackEngine!.play()); + rerender(); + expect(result.current).toBe(true); + }); +}); + +// ── useChange ───────────────────────────────────────────────────────────── + +describe('useChange', () => { + it('30. Returns EMPTY_STATE_CHANGE initially', () => { + const { result } = renderHook(() => useChange(engine)); + expect(result.current.trackIds).toBe(false); + expect(result.current.clipIds.size).toBe(0); + }); + + it('31. clipIds contains changed clip after dispatch', () => { + const { result, rerender } = renderHook(() => useChange(engine)); + act(() => { + engine.dispatch({ + id: 'm', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: CLIP_A, newTimelineStart: toFrame(10) }, + ], + }); + }); + rerender(); + expect(result.current.clipIds.has(CLIP_A)).toBe(true); + }); +}); diff --git a/packages/react/src/__tests__/hooks.test.tsx b/packages/react/src/__tests__/hooks.test.tsx index f949b2a..01d6a84 100644 --- a/packages/react/src/__tests__/hooks.test.tsx +++ b/packages/react/src/__tests__/hooks.test.tsx @@ -27,8 +27,8 @@ import { toTrackId, toClipId, toAssetId, -} from '@timeline/core'; -import type { ClipId, TrackId, Transaction } from '@timeline/core'; +} from '@webpacked-timeline/core'; +import type { ClipId, TrackId, Transaction } from '@webpacked-timeline/core'; import { TimelineProvider, TimelineEngine } from '../index'; import { @@ -120,7 +120,7 @@ function makeWrapper(engine: TimelineEngine) { describe('useEngine', () => { it('returns the engine instance from context', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useEngine(), { wrapper }); expect(result.current).toBe(engine); @@ -133,7 +133,7 @@ describe('useEngine', () => { describe('useTimeline', () => { it('returns the Timeline object from the snapshot', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useTimeline(), { wrapper }); expect(result.current.name).toBe('Hooks Test Timeline'); @@ -141,7 +141,7 @@ describe('useTimeline', () => { }); it('re-renders when timeline name changes', async () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useTimeline(), { wrapper }); expect(result.current.name).toBe('Hooks Test Timeline'); @@ -156,14 +156,14 @@ describe('useTimeline', () => { describe('useTrackIds', () => { it('returns a readonly array of track ids', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useTrackIds(), { wrapper }); expect(result.current).toEqual([TRACK_ID]); }); it('returns the SAME array reference between notifies (stable ref)', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useTrackIds(), { wrapper }); const ref1 = result.current; @@ -186,35 +186,35 @@ describe('useTrackIds', () => { describe('useTrack', () => { it('returns the track matching the given id', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useTrack(TRACK_ID), { wrapper }); expect(result.current?.id).toBe(TRACK_ID); expect(result.current?.clips).toHaveLength(2); }); - it('returns undefined for an unknown track id', () => { - const engine = new TimelineEngine(makeState()); + it('returns null for an unknown track id', () => { + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useTrack(toTrackId('ghost')), { wrapper }); - expect(result.current).toBeUndefined(); + expect(result.current).toBeNull(); }); }); describe('useClip', () => { it('returns the clip matching the given id from committed state', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useClip(CLIP_A_ID), { wrapper }); expect(result.current?.id).toBe(CLIP_A_ID); expect(result.current?.timelineStart).toBe(toFrame(0)); }); - it('returns undefined for a non-existent clip id', () => { - const engine = new TimelineEngine(makeState()); + it('returns null for a non-existent clip id', () => { + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useClip(toClipId('ghost')), { wrapper }); - expect(result.current).toBeUndefined(); + expect(result.current).toBeNull(); }); // ── THE CRITICAL ISOLATION TEST ──────────────────────────────────────────── @@ -237,7 +237,7 @@ describe('useClip', () => { // the identical reference, which is what React uses to gate the re-render. it('ISOLATION: clip B selector returns same reference when only clip A changes', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); // Render two independent hooks — one per clip @@ -266,7 +266,7 @@ describe('useClip', () => { }); it('ISOLATION: clip B renders when clip B itself changes', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const hookB = renderHook(() => useClip(CLIP_B_ID), { wrapper }); @@ -288,7 +288,7 @@ describe('useClip', () => { describe('useActiveTool', () => { it('returns id and cursor string', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useActiveTool(), { wrapper }); expect(typeof result.current.id).toBe('string'); @@ -298,7 +298,7 @@ describe('useActiveTool', () => { describe('useCanUndo / useCanRedo', () => { it('canUndo is false initially, true after a dispatch', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useCanUndo(), { wrapper }); expect(result.current).toBe(false); @@ -311,7 +311,7 @@ describe('useCanUndo / useCanRedo', () => { }); it('canRedo is false initially, true after an undo', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result: undoResult } = renderHook(() => useCanUndo(), { wrapper }); const { result: redoResult } = renderHook(() => useCanRedo(), { wrapper }); @@ -329,7 +329,7 @@ describe('useCanUndo / useCanRedo', () => { describe('useProvisional', () => { it('returns null when not dragging', () => { - const engine = new TimelineEngine(makeState()); + const engine = new TimelineEngine({ initialState: makeState() }); const wrapper = makeWrapper(engine); const { result } = renderHook(() => useProvisional(), { wrapper }); expect(result.current).toBeNull(); diff --git a/packages/react/src/__tests__/integration.test.tsx b/packages/react/src/__tests__/integration.test.tsx new file mode 100644 index 0000000..5b35a4c --- /dev/null +++ b/packages/react/src/__tests__/integration.test.tsx @@ -0,0 +1,794 @@ +/** + * Phase R Step 4 — Integration tests + * + * Full round-trip: dispatch → engine → snapshot → hook → re-render. + * Uses renderHook + act throughout. Playback tests use createTestClock(). + */ + +import type React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + createTimeline, + createTimelineState, + createTrack, + createClip, + createAsset, + toFrame, + toTimecode, + frameRate, + toTrackId, + toClipId, + toAssetId, + createTestClock, +} from '@webpacked-timeline/core'; +import type { PipelineConfig } from '@webpacked-timeline/core'; +import type { VirtualWindow } from '@webpacked-timeline/core'; +import { TimelineEngine } from '../engine'; +import { + useTrackIds, + useTrack, + useClip, + useClips, + useMarkers, + useHistory, + useActiveToolId, + useCursor, + useProvisional, + usePlayheadFrame, + useIsPlaying, +} from '../hooks/index'; +import { useVirtualWindow, useVisibleClips } from '../hooks/use-virtual-window'; +import { createToolRouter } from '../adapter/tool-router'; + +const FPS = 30; +const DURATION_FRAMES = 1800; +const TRACK_H = 48; +const PPF = 10; + +function buildIntegrationEngine(): { + engine: TimelineEngine; + clock: ReturnType['clock']; + tick: ReturnType['tick']; +} { + const { clock, tick } = createTestClock(); + const videoAsset = createAsset({ + id: 'asset-v', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(3000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + const audioAsset = createAsset({ + id: 'asset-a', + name: 'A', + mediaType: 'audio', + filePath: '/a.wav', + intrinsicDuration: toFrame(2000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + + const v1Clip1 = createClip({ + id: 'v1-c1', + assetId: videoAsset.id, + trackId: 'v1', + timelineStart: toFrame(0), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const v1Clip2 = createClip({ + id: 'v1-c2', + assetId: videoAsset.id, + trackId: 'v1', + timelineStart: toFrame(400), + timelineEnd: toFrame(700), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const v1Clip3 = createClip({ + id: 'v1-c3', + assetId: videoAsset.id, + trackId: 'v1', + timelineStart: toFrame(800), + timelineEnd: toFrame(1100), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const v2Clip1 = createClip({ + id: 'v2-c1', + assetId: videoAsset.id, + trackId: 'v2', + timelineStart: toFrame(0), + timelineEnd: toFrame(600), + mediaIn: toFrame(0), + mediaOut: toFrame(600), + }); + const v2Clip2 = createClip({ + id: 'v2-c2', + assetId: videoAsset.id, + trackId: 'v2', + timelineStart: toFrame(700), + timelineEnd: toFrame(1100), + mediaIn: toFrame(0), + mediaOut: toFrame(400), + }); + const a1Clip1 = createClip({ + id: 'a1-c1', + assetId: audioAsset.id, + trackId: 'a1', + timelineStart: toFrame(0), + timelineEnd: toFrame(600), + mediaIn: toFrame(0), + mediaOut: toFrame(600), + }); + + const trackV1 = createTrack({ + id: 'v1', + name: 'V1', + type: 'video', + clips: [v1Clip1, v1Clip2, v1Clip3], + }); + const trackV2 = createTrack({ + id: 'v2', + name: 'V2', + type: 'video', + clips: [v2Clip1, v2Clip2], + }); + const trackA1 = createTrack({ + id: 'a1', + name: 'A1', + type: 'audio', + clips: [a1Clip1], + }); + + const markerPoint = { + type: 'point' as const, + id: 'm1', + frame: toFrame(150), + label: 'M1', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + const markerRange = { + type: 'range' as const, + id: 'm2', + frameStart: toFrame(400), + frameEnd: toFrame(700), + label: 'M2', + color: '#0f0', + scope: 'global' as const, + linkedClipId: null, + }; + + const timeline = createTimeline({ + id: 'tl-int', + name: 'Integration', + fps: FPS, + duration: toFrame(DURATION_FRAMES), + startTimecode: toTimecode('00:00:00:00'), + tracks: [trackV1, trackV2, trackA1], + markers: [markerPoint, markerRange], + }); + + const initialState = createTimelineState({ + timeline, + assetRegistry: new Map([ + [videoAsset.id, videoAsset], + [audioAsset.id, audioAsset], + ]), + }); + + const mockPipeline: PipelineConfig = { + videoDecoder: async (req) => ({ + clipId: req.clipId, + mediaFrame: req.mediaFrame, + width: 1920, + height: 1080, + bitmap: null, + }), + compositor: async (req) => ({ timelineFrame: req.timelineFrame, bitmap: null }), + }; + + const engine = new TimelineEngine({ + initialState, + pipeline: mockPipeline, + dimensions: { width: 1920, height: 1080 }, + clock, + }); + + return { engine, clock, tick }; +} + +function makePointerEvent(overrides: { + clientX?: number; + clientY?: number; + buttons?: number; +} = {}): React.PointerEvent { + return { + clientX: overrides.clientX ?? 0, + clientY: overrides.clientY ?? 0, + buttons: overrides.buttons ?? 1, + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + preventDefault: vi.fn(), + currentTarget: { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 10000, height: 400 }), + }, + } as unknown as React.PointerEvent; +} + +function makeKeyEvent(overrides: { key?: string; code?: string; shiftKey?: boolean } = {}): React.KeyboardEvent { + return { + key: overrides.key ?? ' ', + code: overrides.code ?? 'Space', + shiftKey: overrides.shiftKey ?? false, + altKey: false, + metaKey: false, + ctrlKey: false, + repeat: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent; +} + +// ─── Edit + hooks round-trip ───────────────────────────────────────────── + +describe('Integration — Edit + hooks round-trip', () => { + let engine: TimelineEngine; + + beforeEach(() => { + const built = buildIntegrationEngine(); + engine = built.engine; + }); + + it('1. dispatch INSERT_CLIP → useClips returns new clip', () => { + const trackId = toTrackId('v1'); + const { result } = renderHook(() => useClips(engine, trackId)); + const countBefore = result.current.length; + const newClip = createClip({ + id: 'v1-new', + assetId: toAssetId('asset-v'), + trackId: 'v1', + timelineStart: toFrame(300), + timelineEnd: toFrame(400), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + act(() => { + engine.dispatch({ + id: 'add', + label: 'Add clip', + timestamp: 0, + operations: [{ type: 'INSERT_CLIP', trackId, clip: newClip }], + }); + }); + expect(result.current.length).toBe(countBefore + 1); + expect(result.current.some((c) => c.id === 'v1-new')).toBe(true); + }); + + it('2. dispatch MOVE_CLIP → useClip returns updated startFrame', () => { + const clipId = 'v1-c1'; + const { result } = renderHook(() => useClip(engine, clipId)); + expect((result.current!.timelineStart as number)).toBe(0); + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId(clipId), newTimelineStart: toFrame(100) }, + ], + }); + }); + expect((result.current!.timelineStart as number)).toBe(100); + }); + + it('3. dispatch DELETE_CLIP → useClips no longer contains deleted clip', () => { + const trackId = toTrackId('v1'); + const { result } = renderHook(() => useClips(engine, trackId)); + const idToDelete = 'v1-c2'; + act(() => { + engine.dispatch({ + id: 'del', + label: 'Delete', + timestamp: 0, + operations: [{ type: 'DELETE_CLIP', clipId: toClipId(idToDelete) }], + }); + }); + expect(result.current.some((c) => c.id === idToDelete)).toBe(false); + }); + + it('4. useTrackIds stable after MOVE_CLIP (no track list change → same array ref)', () => { + const { result, rerender } = renderHook(() => useTrackIds(engine)); + const refBefore = result.current; + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId('v1-c1'), newTimelineStart: toFrame(50) }, + ], + }); + }); + rerender(); + expect(result.current).toBe(refBefore); + }); + + it('5. useTrackIds updates after ADD_TRACK', () => { + const { result } = renderHook(() => useTrackIds(engine)); + const countBefore = result.current.length; + const newTrack = createTrack({ + id: 'v3', + name: 'V3', + type: 'video', + clips: [], + }); + act(() => { + engine.dispatch({ + id: 'add-track', + label: 'Add track', + timestamp: 0, + operations: [{ type: 'ADD_TRACK', track: newTrack }], + }); + }); + expect(result.current.length).toBe(countBefore + 1); + expect(result.current).toContain('v3'); + }); + + it('6. useHistory.canUndo true after dispatch', () => { + const { result } = renderHook(() => useHistory(engine)); + expect(result.current.canUndo).toBe(false); + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId('v1-c1'), newTimelineStart: toFrame(10) }, + ], + }); + }); + expect(result.current.canUndo).toBe(true); + }); + + it('7. undo() → useClip returns original position', () => { + const clipId = 'v1-c1'; + const { result } = renderHook(() => useClip(engine, clipId)); + const originalStart = result.current!.timelineStart as number; + act(() => { + const r = engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId(clipId), newTimelineStart: toFrame(1100) }, + ], + }); + expect(r.accepted).toBe(true); + }); + act(() => { + engine.undo(); + }); + expect((result.current!.timelineStart as number)).toBe(originalStart); + }); + + it('8. redo() → useClip returns moved position', () => { + const clipId = 'v1-c1'; + const newStart = 1100; + const { result } = renderHook(() => useClip(engine, clipId)); + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId(clipId), newTimelineStart: toFrame(newStart) }, + ], + }); + }); + act(() => { + engine.undo(); + }); + expect((result.current!.timelineStart as number)).toBe(0); + let didRedo: boolean; + act(() => { + didRedo = engine.redo(); + }); + expect(didRedo).toBe(true); + const clip = engine.getSnapshot().state.timeline.tracks[0]!.clips.find((c) => c.id === clipId); + expect(clip!.timelineStart).toBe(newStart); + expect((result.current!.timelineStart as number)).toBe(newStart); + }); + + it('9. dispatch ADD_MARKER → useMarkers updates', () => { + const { result } = renderHook(() => useMarkers(engine)); + const countBefore = result.current.length; + const newMarker = { + type: 'point' as const, + id: 'm3', + frame: toFrame(500), + label: 'M3', + color: '#00f', + scope: 'global' as const, + linkedClipId: null, + }; + act(() => { + engine.dispatch({ + id: 'add-marker', + label: 'Add marker', + timestamp: 0, + operations: [{ type: 'ADD_MARKER', marker: newMarker }], + }); + }); + expect(result.current.length).toBe(countBefore + 1); + expect(result.current.some((m) => m.id === 'm3')).toBe(true); + }); + + it('10. useClip isolation: dispatch MOVE_CLIP on clip A → render count for clip B unchanged', () => { + const clipAId = 'v1-c1'; + const clipBId = 'v2-c1'; + let clipBRenderCount = 0; + const { result } = renderHook(() => { + const clipB = useClip(engine, clipBId); + clipBRenderCount++; + return clipB; + }); + expect(clipBRenderCount).toBe(1); + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move A', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId(clipAId), newTimelineStart: toFrame(20) }, + ], + }); + }); + expect(result.current).not.toBeNull(); + expect(result.current!.id).toBe(clipBId); + expect(clipBRenderCount).toBe(1); + }); +}); + +// ─── Tool interaction round-trip ──────────────────────────────────────── + +describe('Integration — Tool interaction round-trip', () => { + let engine: TimelineEngine; + + beforeEach(() => { + const built = buildIntegrationEngine(); + engine = built.engine; + }); + + it('11. Activate RazorTool → useActiveToolId === "razor"', () => { + const { result } = renderHook(() => useActiveToolId(engine)); + expect(result.current).toBe('selection'); + act(() => { + engine.activateTool('razor'); + }); + expect(result.current).toBe('razor'); + }); + + it('12. Activate SelectionTool → cursor is "default"', () => { + act(() => { + engine.activateTool('razor'); + }); + const { result } = renderHook(() => useCursor(engine)); + act(() => { + engine.activateTool('selection'); + }); + expect(result.current).toBe('default'); + }); + + it('13. handlePointerDown + handlePointerUp with RazorTool dispatches SPLIT → useClips has one more clip', () => { + const trackId = toTrackId('v1'); + const { result } = renderHook(() => useClips(engine, trackId)); + const countBefore = result.current.length; + act(() => { + engine.activateTool('razor'); + }); + const frameInClip = 450; + const modifiers = { shift: false, alt: false, ctrl: false, meta: false }; + const syntheticEvent = { + frame: toFrame(frameInClip), + trackId: toTrackId('v1'), + clipId: toClipId('v1-c2'), + x: frameInClip * PPF, + y: TRACK_H * 0.5, + buttons: 1, + shiftKey: false, + altKey: false, + metaKey: false, + }; + act(() => { + engine.handlePointerDown(syntheticEvent, modifiers); + }); + act(() => { + engine.handlePointerUp( + { ...syntheticEvent, buttons: 0 }, + modifiers, + ); + }); + expect(result.current.length).toBe(countBefore + 1); + }); + + it('14. handlePointerLeave clears provisional state → useProvisional returns null', () => { + act(() => { + engine.activateTool('selection'); + }); + const handlers = createToolRouter({ + engine, + getPixelsPerFrame: () => PPF, + }); + const rect = { left: 0, top: 0, width: 10000, height: 400 }; + const down = makePointerEvent({ clientX: 100, clientY: TRACK_H / 2, buttons: 1 }); + (down as { currentTarget: { getBoundingClientRect: () => typeof rect } }).currentTarget = { + getBoundingClientRect: () => rect, + }; + act(() => { + handlers.onPointerDown(down); + }); + const { result } = renderHook(() => useProvisional(engine)); + const leave = makePointerEvent({ clientX: 0, clientY: 0, buttons: 0 }); + (leave as { currentTarget: { getBoundingClientRect: () => typeof rect } }).currentTarget = { + getBoundingClientRect: () => rect, + }; + act(() => { + handlers.onPointerLeave(leave); + }); + expect(result.current).toBeNull(); + }); +}); + +// ─── Playback + edit simultaneous ───────────────────────────────────────── + +describe('Integration — Playback + edit simultaneous', () => { + it('15. play() → useIsPlaying true', () => { + const { engine } = buildIntegrationEngine(); + const { result } = renderHook(() => useIsPlaying(engine)); + expect(result.current).toBe(false); + act(() => { + engine.playbackEngine!.play(); + }); + expect(result.current).toBe(true); + }); + + it('16. pause() → useIsPlaying false', () => { + const { engine } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.play(); + }); + const { result } = renderHook(() => useIsPlaying(engine)); + expect(result.current).toBe(true); + act(() => { + engine.playbackEngine!.pause(); + }); + expect(result.current).toBe(false); + }); + + it('17. dispatch during playback does not crash', () => { + const { engine, tick } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.play(); + }); + act(() => { + tick(100); + }); + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId('v1-c1'), newTimelineStart: toFrame(50) }, + ], + }); + }); + expect(engine.getSnapshot().state.timeline.tracks[0]!.clips[0]!.timelineStart).toBe(50); + }); + + it('18. seekTo(300) → usePlayheadFrame === 300', () => { + const { engine } = buildIntegrationEngine(); + const { result } = renderHook(() => usePlayheadFrame(engine)); + act(() => { + engine.playbackEngine!.seekTo(toFrame(300)); + }); + expect(result.current).toBe(300); + }); + + it('19. tick(1000ms at 30fps) → usePlayheadFrame advanced', () => { + const { engine, tick } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.seekTo(toFrame(0)); + engine.playbackEngine!.play(); + }); + act(() => { + tick(0); + for (let i = 0; i < 20; i++) tick(50); + }); + const playheadState = engine.playbackEngine!.getState(); + const frameAfter = playheadState.currentFrame as number; + expect(frameAfter).toBeGreaterThan(0); + expect(frameAfter).toBeLessThanOrEqual(1800); + }); + + it('20. loop region: setLoopRegion → play → tick past end → usePlayheadFrame wraps', () => { + const { engine, tick } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.setLoopRegion({ + startFrame: toFrame(100), + endFrame: toFrame(150), + }); + }); + act(() => { + engine.playbackEngine!.seekTo(toFrame(100)); + }); + act(() => { + engine.playbackEngine!.play(); + }); + act(() => { + tick(2000); + }); + const frame = engine.getSnapshot().playhead.currentFrame as number; + expect(frame).toBeGreaterThanOrEqual(100); + expect(frame).toBeLessThan(150); + }); +}); + +// ─── Virtual rendering ──────────────────────────────────────────────────── + +describe('Integration — Virtual rendering', () => { + let engine: TimelineEngine; + + beforeEach(() => { + const built = buildIntegrationEngine(); + engine = built.engine; + }); + + it('21. useVisibleClips with window 0–300 returns clips in that range', () => { + const window: VirtualWindow = { + startFrame: toFrame(0), + endFrame: toFrame(300), + pixelsPerFrame: PPF, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const visible = result.current.filter((e) => e.isVisible); + expect(visible.length).toBeGreaterThanOrEqual(2); + visible.forEach((e) => { + expect((e.clip.timelineEnd as number) > 0).toBe(true); + expect((e.clip.timelineStart as number) < 300).toBe(true); + }); + }); + + it('22. useVisibleClips updates when new clip dispatched inside window', () => { + const window: VirtualWindow = { + startFrame: toFrame(0), + endFrame: toFrame(500), + pixelsPerFrame: PPF, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const countBefore = result.current.filter((e) => e.isVisible).length; + const newClip = createClip({ + id: 'v1-gap', + assetId: toAssetId('asset-v'), + trackId: 'v1', + timelineStart: toFrame(300), + timelineEnd: toFrame(400), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + act(() => { + engine.dispatch({ + id: 'add', + label: 'Add', + timestamp: 0, + operations: [ + { type: 'INSERT_CLIP', trackId: toTrackId('v1'), clip: newClip }, + ], + }); + }); + expect(result.current.filter((e) => e.isVisible).length).toBe(countBefore + 1); + }); + + it('23. Clip outside window has isVisible: false', () => { + const window: VirtualWindow = { + startFrame: toFrame(1500), + endFrame: toFrame(1800), + pixelsPerFrame: PPF, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const entries = result.current; + const v1Clips = entries.filter((e) => e.track.id === 'v1'); + v1Clips.forEach((e) => { + const end = e.clip.timelineEnd as number; + const start = e.clip.timelineStart as number; + expect(end <= 1500 || start >= 1800 ? !e.isVisible : true).toBe(true); + }); + }); +}); + +// ─── Compression ───────────────────────────────────────────────────────── + +describe('Integration — Compression', () => { + it('24. Rapid MOVE_CLIP dispatches (5 in sequence) → undo() restores position from BEFORE all 5', () => { + const { engine } = buildIntegrationEngine(); + const clipId = toClipId('v1-c1'); + const { result } = renderHook(() => useClip(engine, clipId)); + const originalStart = result.current!.timelineStart as number; + act(() => { + for (let i = 1; i <= 5; i++) { + engine.dispatch({ + id: `move-${i}`, + label: `Move ${i}`, + timestamp: i, + operations: [ + { type: 'MOVE_CLIP', clipId, newTimelineStart: toFrame(originalStart + i * 10) }, + ], + }); + } + }); + expect((result.current!.timelineStart as number)).toBe(originalStart + 50); + act(() => { + engine.undo(); + }); + expect((result.current!.timelineStart as number)).toBe(originalStart); + }); +}); + +// ─── Keyboard ───────────────────────────────────────────────────────────── + +describe('Integration — Keyboard', () => { + it('25. handleKeyDown Space → useIsPlaying true (requires pipeline)', () => { + const { engine } = buildIntegrationEngine(); + const { result } = renderHook(() => useIsPlaying(engine)); + const handlers = createToolRouter({ + engine, + getPixelsPerFrame: () => PPF, + }); + act(() => { + handlers.onKeyDown(makeKeyEvent({ key: ' ', code: 'Space' })); + }); + expect(result.current).toBe(true); + }); + + it('26. handleKeyDown ArrowRight → usePlayheadFrame advanced by 1', () => { + const { engine } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.seekTo(toFrame(100)); + }); + const { result } = renderHook(() => usePlayheadFrame(engine)); + const handlers = createToolRouter({ + engine, + getPixelsPerFrame: () => PPF, + }); + act(() => { + handlers.onKeyDown(makeKeyEvent({ key: 'ArrowRight', code: 'ArrowRight' })); + }); + expect(result.current).toBe(101); + }); + + it('27. handleKeyDown Shift+ArrowRight → seekToNextClipBoundary → usePlayheadFrame at next boundary', () => { + const { engine } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.seekTo(toFrame(0)); + }); + const { result } = renderHook(() => usePlayheadFrame(engine)); + const handlers = createToolRouter({ + engine, + getPixelsPerFrame: () => PPF, + }); + act(() => { + handlers.onKeyDown( + makeKeyEvent({ key: 'ArrowRight', code: 'ArrowRight', shiftKey: true }), + ); + }); + expect(result.current).toBe(300); + }); +}); diff --git a/packages/react/src/__tests__/timeline-provider.test.tsx b/packages/react/src/__tests__/timeline-provider.test.tsx index 6fd7c5e..f2a646d 100644 --- a/packages/react/src/__tests__/timeline-provider.test.tsx +++ b/packages/react/src/__tests__/timeline-provider.test.tsx @@ -10,9 +10,9 @@ import { createTimelineState, toFrame, frameRate, -} from '@timeline/core'; +} from '@webpacked-timeline/core'; import { - TimelineEngine, // Phase 1 engine from @timeline/react + TimelineEngine, // Phase 1 engine from @webpacked-timeline/react TimelineProvider, useTimeline, } from '../index'; @@ -30,7 +30,7 @@ describe('TimelineProvider', () => { tracks: [], }); const state = createTimelineState({ timeline }); - engine = new TimelineEngine(state); + engine = new TimelineEngine({ initialState: state }); }); it('should provide timeline state via useTimeline hook', () => { diff --git a/packages/react/src/__tests__/tool-router-r3.test.ts b/packages/react/src/__tests__/tool-router-r3.test.ts new file mode 100644 index 0000000..b4753f2 --- /dev/null +++ b/packages/react/src/__tests__/tool-router-r3.test.ts @@ -0,0 +1,384 @@ +/** + * Phase R Step 3 — ToolRouter (adapter) + useToolRouter + virtual hooks + * + * Fixture: engine with 1 track, 2 clips. + * Mock React.PointerEvent / React.KeyboardEvent with plain objects. + */ + +import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + createTimeline, + createTimelineState, + createTrack, + createClip, + createAsset, + toFrame, + toTimecode, + frameRate, + toTrackId, + toAssetId, + NoOpTool, +} from '@webpacked-timeline/core'; +import type { VirtualWindow } from '@webpacked-timeline/core'; + +import { TimelineEngine } from '../engine'; +import { createToolRouter } from '../adapter/tool-router'; +import { useToolRouter } from '../hooks/use-tool-router'; +import { useVirtualWindow } from '../hooks/use-virtual-window'; +import { useVisibleClips } from '../hooks/use-virtual-window'; + +// ── Fixture: 1 track, 2 clips ─────────────────────────────────────────────── + +function makeFixtureState() { + const asset = createAsset({ + id: 'asset-1', + name: 'Asset', + mediaType: 'video', + filePath: '/a.mp4', + intrinsicDuration: toFrame(600), + nativeFps: frameRate(30), + sourceTimecodeOffset: toFrame(0), + }); + const clipA = createClip({ + id: 'clip-a', + assetId: asset.id, + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clipB = createClip({ + id: 'clip-b', + assetId: asset.id, + trackId: 'track-1', + timelineStart: toFrame(150), + timelineEnd: toFrame(250), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track = createTrack({ + id: 'track-1', + name: 'V1', + type: 'video', + clips: [clipA, clipB], + }); + const timeline = createTimeline({ + id: 'tl-r3', + name: 'R3', + fps: frameRate(30), + duration: toFrame(3000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ + timeline, + assetRegistry: new Map([[asset.id, asset]]), + }); +} + +let engine: TimelineEngine; +const ppf = 10; +let scrollLeft = 0; + +function makeReactPointerEvent(overrides: Partial<{ + clientX: number; + clientY: number; + buttons: number; + shiftKey: boolean; + ctrlKey: boolean; +}> = {}): React.PointerEvent { + return { + clientX: overrides.clientX ?? 50, + clientY: overrides.clientY ?? 24, + buttons: overrides.buttons ?? 1, + shiftKey: overrides.shiftKey ?? false, + altKey: false, + metaKey: false, + ctrlKey: overrides.ctrlKey ?? false, + preventDefault: vi.fn(), + currentTarget: { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 1000, height: 400 }), + }, + } as unknown as React.PointerEvent; +} + +function makeReactKeyEvent(overrides: Partial<{ + key: string; + code: string; + shiftKey: boolean; + preventDefault: () => void; +}> = {}): React.KeyboardEvent { + return { + key: overrides.key ?? 'x', + code: overrides.code ?? 'KeyX', + shiftKey: overrides.shiftKey ?? false, + altKey: false, + metaKey: false, + ctrlKey: false, + repeat: false, + preventDefault: overrides.preventDefault ?? vi.fn(), + } as unknown as React.KeyboardEvent; +} + +beforeEach(() => { + engine = new TimelineEngine({ + initialState: makeFixtureState(), + tools: [NoOpTool], + defaultToolId: 'noop', + }); + scrollLeft = 0; +}); + +// ── createToolRouter ──────────────────────────────────────────────────────── + +describe('createToolRouter', () => { + it('1. Returns object with 5 handlers', () => { + const router = createToolRouter({ + engine, + getPixelsPerFrame: () => ppf, + getScrollLeft: () => scrollLeft, + }); + expect(router).toHaveProperty('onPointerDown'); + expect(router).toHaveProperty('onPointerMove'); + expect(router).toHaveProperty('onPointerUp'); + expect(router).toHaveProperty('onPointerLeave'); + expect(router).toHaveProperty('onKeyDown'); + expect(typeof router.onPointerDown).toBe('function'); + expect(typeof router.onPointerMove).toBe('function'); + expect(typeof router.onPointerUp).toBe('function'); + expect(typeof router.onPointerLeave).toBe('function'); + expect(typeof router.onKeyDown).toBe('function'); + }); + + it('2. onPointerDown calls engine.handlePointerDown', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const spy = vi.spyOn(engine, 'handlePointerDown'); + const e = makeReactPointerEvent(); + router.onPointerDown(e); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![0]).toMatchObject({ frame: 5, x: 50, y: 24 }); + }); + + it('3. onPointerUp calls engine.handlePointerUp', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const spy = vi.spyOn(engine, 'handlePointerUp'); + router.onPointerUp(makeReactPointerEvent()); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('4. onPointerMove is rAF-throttled: 3 sync calls → 1 engine.handlePointerMove after flush', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const spy = vi.spyOn(engine, 'handlePointerMove'); + let rafCallback: FrameRequestCallback | null = null; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafCallback = cb; + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => { + rafCallback = null; + }); + + router.onPointerMove(makeReactPointerEvent({ clientX: 10 })); + router.onPointerMove(makeReactPointerEvent({ clientX: 20 })); + router.onPointerMove(makeReactPointerEvent({ clientX: 30 })); + expect(spy).toHaveBeenCalledTimes(0); + expect(rafCallback).not.toBeNull(); + rafCallback!(0); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![0].x).toBe(30); + + vi.unstubAllGlobals(); + }); + + it('5. onPointerLeave calls engine.handlePointerUp AND engine.handlePointerLeave', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const upSpy = vi.spyOn(engine, 'handlePointerUp'); + const leaveSpy = vi.spyOn(engine, 'handlePointerLeave'); + router.onPointerLeave(makeReactPointerEvent()); + expect(upSpy).toHaveBeenCalledTimes(1); + expect(leaveSpy).toHaveBeenCalledTimes(1); + }); + + it('6. onKeyDown calls engine.handleKeyDown', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const spy = vi.spyOn(engine, 'handleKeyDown'); + router.onKeyDown(makeReactKeyEvent({ key: 'z', code: 'KeyZ' })); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('7. onKeyDown calls e.preventDefault() when handler returns true', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const e = makeReactKeyEvent({ key: ' ', code: 'Space' }); + vi.spyOn(engine, 'handleKeyDown').mockReturnValue(true); + router.onKeyDown(e); + expect(e.preventDefault).toHaveBeenCalled(); + }); + + it('8. onKeyDown does NOT call e.preventDefault() when handler returns false', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const e = makeReactKeyEvent({ key: 'z' }); + vi.spyOn(engine, 'handleKeyDown').mockReturnValue(false); + router.onKeyDown(e); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + it('9. convertPointerEvent includes scrollLeft offset in x coordinate', () => { + scrollLeft = 100; + const router = createToolRouter({ + engine, + getPixelsPerFrame: () => ppf, + getScrollLeft: () => scrollLeft, + }); + const spy = vi.spyOn(engine, 'handlePointerDown'); + const e = makeReactPointerEvent({ clientX: 50 }); + router.onPointerDown(e); + expect(spy.mock.calls[0]![0].x).toBe(150); + expect(spy.mock.calls[0]![0].frame).toBe(15); + }); + + it('10. Modifiers (shiftKey, ctrlKey) passed through', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const spy = vi.spyOn(engine, 'handlePointerDown'); + router.onPointerDown( + makeReactPointerEvent({ shiftKey: true, ctrlKey: true }), + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![1]).toEqual({ + shift: true, + alt: false, + ctrl: true, + meta: false, + }); + }); +}); + +// ── useToolRouter ────────────────────────────────────────────────────────── + +describe('useToolRouter', () => { + it('11. Returns stable handlers reference across re-renders (same engine)', () => { + const { result, rerender } = renderHook( + () => + useToolRouter(engine, { + getPixelsPerFrame: () => ppf, + }), + ); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); + + it('12. Returns new handlers if engine changes', () => { + const engine2 = new TimelineEngine({ + initialState: makeFixtureState(), + tools: [NoOpTool], + defaultToolId: 'noop', + }); + const { result, rerender } = renderHook( + ({ eng }) => + useToolRouter(eng, { getPixelsPerFrame: () => ppf }), + { initialProps: { eng: engine } }, + ); + const first = result.current; + rerender({ eng: engine2 }); + expect(result.current).not.toBe(first); + }); +}); + +// ── useVirtualWindow ──────────────────────────────────────────────────────── + +describe('useVirtualWindow', () => { + it('13. Returns correct startFrame and endFrame for given viewport/scroll/ppf', () => { + const { result } = renderHook(() => + useVirtualWindow(engine, 100, 0, 10), + ); + expect(result.current.startFrame).toBe(0); + expect(result.current.endFrame).toBe(10); + expect(result.current.pixelsPerFrame).toBe(10); + }); + + it('14. Returns new window when scrollLeft changes', () => { + const { result, rerender } = renderHook( + ({ scroll }: { scroll: number }) => + useVirtualWindow(engine, 100, scroll, 10), + { initialProps: { scroll: 0 } }, + ); + const w0 = result.current; + rerender({ scroll: 50 }); + expect(result.current).not.toBe(w0); + expect(result.current.startFrame).toBe(5); + }); + + it('15. Returns same reference when nothing changes', () => { + const { result, rerender } = renderHook(() => + useVirtualWindow(engine, 100, 0, 10), + ); + const w0 = result.current; + rerender(); + expect(result.current).toBe(w0); + }); +}); + +// ── useVisibleClips ───────────────────────────────────────────────────────── + +describe('useVisibleClips', () => { + it('16. Returns only visible clips for current window', () => { + const window: VirtualWindow = { + startFrame: toFrame(0), + endFrame: toFrame(200), + pixelsPerFrame: 10, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const entries = result.current; + expect(entries.length).toBeGreaterThanOrEqual(2); + const visible = entries.filter((e) => e.isVisible); + expect(visible.length).toBe(2); + }); + + it('17. Updates when engine state changes (new clip added inside window)', () => { + const window: VirtualWindow = { + startFrame: toFrame(0), + endFrame: toFrame(500), + pixelsPerFrame: 10, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const countBefore = result.current.filter((e) => e.isVisible).length; + const newClip = createClip({ + id: 'clip-c', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(100), + timelineEnd: toFrame(150), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + }); + let dispatched: { accepted: boolean }; + act(() => { + dispatched = engine.dispatch({ + id: 'add', + label: 'Add clip', + timestamp: 0, + operations: [ + { type: 'INSERT_CLIP', trackId: toTrackId('track-1'), clip: newClip }, + ], + }); + }); + expect(dispatched!.accepted).toBe(true); + const countAfter = result.current.filter((e) => e.isVisible).length; + expect(countAfter).toBe(countBefore + 1); + }); + + it('18. isVisible false for clips outside window', () => { + const window: VirtualWindow = { + startFrame: toFrame(1000), + endFrame: toFrame(1100), + pixelsPerFrame: 10, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const entries = result.current; + const visible = entries.filter((e) => e.isVisible); + expect(visible.length).toBe(0); + }); +}); diff --git a/packages/react/src/__tests__/tool-router.test.ts b/packages/react/src/__tests__/tool-router.test.ts index 225406c..f826ad1 100644 --- a/packages/react/src/__tests__/tool-router.test.ts +++ b/packages/react/src/__tests__/tool-router.test.ts @@ -36,8 +36,8 @@ import { toTrackId, toClipId, toAssetId, -} from '@timeline/core'; -import type { TimelinePointerEvent, TimelineKeyEvent, Modifiers } from '@timeline/core'; +} from '@webpacked-timeline/core'; +import type { TimelinePointerEvent, TimelineKeyEvent, Modifiers } from '@webpacked-timeline/core'; import { TimelineEngine } from '../index'; @@ -256,7 +256,7 @@ describe('createToolRouter', () => { return rafCallbacks.length; }); - engine = new TimelineEngine(makeState()); + engine = new TimelineEngine({ initialState: makeState() }); router = createToolRouter(engine, () => DEFAULT_LAYOUT); }); diff --git a/packages/react/src/__tests__/usePlayhead.test.ts b/packages/react/src/__tests__/usePlayhead.test.ts index 6bb966b..3fd5140 100644 --- a/packages/react/src/__tests__/usePlayhead.test.ts +++ b/packages/react/src/__tests__/usePlayhead.test.ts @@ -18,9 +18,9 @@ import { toTrackId, createTestClock, PlaybackEngine, -} from '@timeline/core'; -import type { TimelineState } from '@timeline/core'; -import type { PipelineConfig } from '@timeline/core'; +} from '@webpacked-timeline/core'; +import type { TimelineState } from '@webpacked-timeline/core'; +import type { PipelineConfig } from '@webpacked-timeline/core'; import { usePlayhead, usePlayheadEvent } from '../hooks'; const FPS = 30; diff --git a/packages/react/src/adapter/tool-router.ts b/packages/react/src/adapter/tool-router.ts new file mode 100644 index 0000000..6af6150 --- /dev/null +++ b/packages/react/src/adapter/tool-router.ts @@ -0,0 +1,171 @@ +/** + * ToolRouter — Phase R Step 3 + * + * Converts React pointer/keyboard events into engine events. + * rAF throttle only on onPointerMove. Option Y: onPointerLeave + * calls handlePointerUp then handlePointerLeave. + */ + +import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import type { TimelineEngine } from '../engine'; +import type { TimelinePointerEvent, TimelineKeyEvent, Modifiers, ClipId, TrackId } from '@webpacked-timeline/core'; +import type { TimelineFrame } from '@webpacked-timeline/core'; + +export type ToolRouterOptions = { + engine: TimelineEngine; + getPixelsPerFrame: () => number; + getScrollLeft?: () => number; +}; + +export type ToolRouterHandlers = { + onPointerDown: (e: ReactPointerEvent) => void; + onPointerMove: (e: ReactPointerEvent) => void; + onPointerUp: (e: ReactPointerEvent) => void; + onPointerLeave: (e: ReactPointerEvent) => void; + onKeyDown: (e: ReactKeyboardEvent) => void; +}; + +function getScrollLeftDefault(): number { + return 0; +} + +const EDGE_HIT_PX = 8; + +function convertPointerEvent( + e: ReactPointerEvent, + getPixelsPerFrame: () => number, + getScrollLeft: () => number, +): TimelinePointerEvent { + const ppf = getPixelsPerFrame(); + const sl = getScrollLeft(); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + + // x is in timeline coordinate space (scroll-adjusted, from timeline origin) + const x = e.clientX - rect.left + sl; + const y = e.clientY - rect.top; + + // Frame from x position + const frame = Math.max(0, Math.round(x / ppf)) as TimelineFrame; + + // DOM hit-test: walk up from target + let clipId: string | undefined; + let trackId: string | undefined; + let edge: 'left' | 'right' | 'none' = 'none'; + let clipEl: HTMLElement | null = null; + + let el: HTMLElement | null = e.target as HTMLElement | null; + while (el && el !== e.currentTarget) { + if (!clipId && el.dataset.clipId) { + clipId = el.dataset.clipId; + clipEl = el; + } + if (!trackId && el.dataset.trackId) { + trackId = el.dataset.trackId; + } + if (clipId && trackId) break; + el = el.parentElement; + } + + // Edge detection + if (clipId && clipEl) { + const cr = clipEl.getBoundingClientRect(); + const localX = e.clientX - cr.left; + const thresh = Math.min(EDGE_HIT_PX, cr.width * 0.15); + if (localX <= thresh) edge = 'left'; + else if (localX >= cr.width - thresh) edge = 'right'; + } + + return { + frame, + trackId: (trackId as TrackId) ?? null, + clipId: (clipId as ClipId) ?? null, + x, + y, + buttons: e.buttons, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + edge, + }; +} + +function extractModifiers(e: ReactPointerEvent | ReactKeyboardEvent): Modifiers { + return { + shift: e.shiftKey, + alt: e.altKey, + ctrl: e.ctrlKey, + meta: e.metaKey, + }; +} + +function convertKeyEvent(e: ReactKeyboardEvent): TimelineKeyEvent { + return { + code: e.code, + key: e.key, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + repeat: e.repeat, + }; +} + +export function createToolRouter(options: ToolRouterOptions): ToolRouterHandlers { + const { engine } = options; + const getScrollLeft = options.getScrollLeft ?? getScrollLeftDefault; + const getPixelsPerFrame = options.getPixelsPerFrame; + + let rafId: number | null = null; + let lastMoveEvent: ReactPointerEvent | null = null; + let lastModifiers: Modifiers | null = null; + + return { + onPointerDown(e: ReactPointerEvent): void { + const converted = convertPointerEvent(e, getPixelsPerFrame, getScrollLeft); + engine.handlePointerDown(converted, extractModifiers(e)); + }, + + onPointerMove(e: ReactPointerEvent): void { + e.preventDefault(); + lastMoveEvent = e; + lastModifiers = extractModifiers(e); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + rafId = null; + if (lastMoveEvent !== null && lastModifiers !== null) { + const converted = convertPointerEvent( + lastMoveEvent, + getPixelsPerFrame, + getScrollLeft, + ); + engine.handlePointerMove(converted, lastModifiers); + } + }); + }, + + onPointerUp(e: ReactPointerEvent): void { + const converted = convertPointerEvent(e, getPixelsPerFrame, getScrollLeft); + engine.handlePointerUp(converted, extractModifiers(e)); + }, + + onPointerLeave(e: ReactPointerEvent): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + lastMoveEvent = null; + lastModifiers = null; + const converted = convertPointerEvent(e, getPixelsPerFrame, getScrollLeft); + engine.handlePointerUp(converted, extractModifiers(e)); + engine.handlePointerLeave(converted); + }, + + onKeyDown(e: ReactKeyboardEvent): void { + const converted = convertKeyEvent(e); + const handled = engine.handleKeyDown(converted, extractModifiers(e)); + if (handled) e.preventDefault(); + }, + }; +} diff --git a/packages/react/src/engine.ts b/packages/react/src/engine.ts index c9dd38b..6f2609a 100644 --- a/packages/react/src/engine.ts +++ b/packages/react/src/engine.ts @@ -1,47 +1,47 @@ /** - * TimelineEngine — Phase 1 + * TimelineEngine — Phase R Step 1 (full orchestrator) * - * The ONLY class in the Phase 1 system. Lives in packages/react. - * Wires @timeline/core pure functions together and exposes what React needs. + * Wires @webpacked-timeline/core: HistoryStack, PlaybackEngine, SnapIndexManager, + * TrackIndex, KeyboardHandler, dispatch, diffStates. Exposes EngineSnapshot + * for useSyncExternalStore. * - * IMMUTABILITY CONTRACT: - * All core state changes go through coreDispatch(). - * Engine private fields are assigned (not mutated) on every change. - * - * notify() FIRES IN EXACTLY THREE SITUATIONS: - * 1. After a Transaction is accepted by dispatch() - * 2. After provisional state changes (set or clear) - * — includes handlePointerDown (cursor), handlePointerMove, handlePointerUp - * 3. After activateTool() — cursor needs to update - * - * notify() NEVER FIRES: - * - During buildToolContext() - * - During snap index rebuild (queueMicrotask) - * - More than once per handlePointerMove call - * - In setPixelsPerFrame() — ppf is not in EngineSnapshot - * - In handleKeyDown when tx is null + * No DOM dependencies — clock and getPixelsPerFrame are injected. */ -// Core pure functions — sourced from @timeline/core public API import { dispatch as coreDispatch, - createHistory, - pushHistory, - undo as historyUndo, - redo as historyRedo, - canUndo as historyCanUndo, - canRedo as historyCanRedo, - buildSnapIndex, - nearest, - createRegistry, - activateTool as registryActivate, - getActiveTool, - NoOpTool, + HistoryStack, + DEFAULT_COMPRESSION_POLICY, + diffStates, + EMPTY_STATE_CHANGE, createProvisionalManager, setProvisional, clearProvisional, + createRegistry, + activateTool as registryActivateTool, + getActiveTool, + buildSnapIndex, + nearest, + SnapIndexManager, + TrackIndex, + PlaybackEngine, + browserClock, + KeyboardHandler, + SelectionTool, + RazorTool, + RippleTrimTool, + RollTrimTool, + SlipTool, + SlideTool, + RippleDeleteTool, + RippleInsertTool, + HandTool, + TransitionTool, + KeyframeTool, + createZoomTool, + toFrame, toToolId, -} from '@timeline/core'; +} from '@webpacked-timeline/core'; import type { TimelineState, @@ -49,49 +49,42 @@ import type { DispatchResult, TimelineFrame, TrackId, - SnapIndex, ITool, ToolId, ToolContext, Modifiers, TimelinePointerEvent, TimelineKeyEvent, - ProvisionalState, ToolRegistry, ProvisionalManager, - HistoryState, -} from '@timeline/core'; + StateChange, + SnapPointType, + SnapIndex, +} from '@webpacked-timeline/core'; -// --------------------------------------------------------------------------- -// EngineSnapshot — the single object useSyncExternalStore reads -// --------------------------------------------------------------------------- +import type { EngineSnapshot, TimelineEngineOptions } from './types/engine-snapshot'; +import { DEFAULT_PLAYHEAD_STATE } from './types/engine-snapshot'; -/** - * One object. One subscribe(). One getSnapshot(). - * Hooks destructure what they need — no dual-store coordination required. - * - * pixelsPerFrame is NOT here — zoom is UI state passed as a prop. - * snapIndex is NOT here — internal to engine, accessed only via buildToolContext(). - */ -export type EngineSnapshot = { - readonly state: TimelineState; - readonly provisional: ProvisionalState | null; // null when not dragging - readonly activeToolId: ToolId; - readonly cursor: string; // getCursor() at idle modifiers - readonly canUndo: boolean; - readonly canRedo: boolean; - readonly trackIds: readonly TrackId[]; // stable ref until tracks change -}; +export type { EngineSnapshot, TimelineEngineOptions } from './types/engine-snapshot'; +export { DEFAULT_PLAYHEAD_STATE } from './types/engine-snapshot'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const SNAP_RADIUS_PX = 8; -const DEFAULT_PPF = 10; // 10px per frame — overridden by setPixelsPerFrame -const DEFAULT_TRACK_H = 48; // 48px per track — Phase 2 replaces with measured values +const SNAP_RADIUS_PX = 8; +const DEFAULT_TRACK_H = 48; +const DEFAULT_PPF = 10; + +/** No-op transaction for initial history entry */ +const NOOP_TRANSACTION: Transaction = { + id: 'initial', + label: 'initial', + timestamp: 0, + operations: [], +}; -/** Modifiers state when no keys are held — used for idle cursor computation */ +/** Idle modifiers for cursor computation */ const IDLE_MODIFIERS: Modifiers = { shift: false, alt: false, ctrl: false, meta: false }; // --------------------------------------------------------------------------- @@ -99,253 +92,438 @@ const IDLE_MODIFIERS: Modifiers = { shift: false, alt: false, ctrl: false, meta: // --------------------------------------------------------------------------- export class TimelineEngine { - // ── Private state ───────────────────────────────────────────────────────── - private _state: TimelineState; - private _history: HistoryState; - private _snapIndex: SnapIndex; - private _registry: ToolRegistry; - private _provisional: ProvisionalManager; - private _snapshot: EngineSnapshot; - private _listeners: Set<() => void>; - private _ppf: number; // pixelsPerFrame — internal only, NOT in snapshot - private _playhead: TimelineFrame; - - // ── Constructor ────────────────────────────────────────────────────────── - - constructor( - initial: TimelineState, - tools: readonly ITool[] = [NoOpTool], - defaultToolId: ToolId = toToolId('noop'), - ) { - this._state = initial; - this._history = createHistory(initial); - this._snapIndex = buildSnapIndex(initial, 0 as TimelineFrame); - this._registry = createRegistry(tools, defaultToolId); - this._provisional = createProvisionalManager(); - this._listeners = new Set(); - this._ppf = DEFAULT_PPF; - this._playhead = 0 as TimelineFrame; - this._snapshot = this.buildSnapshot(); - - // Bind so callers can destructure: const { subscribe, getSnapshot } = engine - this.subscribe = this.subscribe.bind(this); + private currentState: TimelineState; + private history: HistoryStack; + private provisional: ProvisionalManager; + private toolRegistry: ToolRegistry; + private keyboardHandler: KeyboardHandler; + private playback: PlaybackEngine | null; + private snapManager: SnapIndexManager; + private trackIndex: TrackIndex; + private subscribers: Set<() => void>; + private snapshot: EngineSnapshot; + private prevState: TimelineState; + private stableTrackIds: readonly string[]; + private options: TimelineEngineOptions; + /** Cached for stable snapshot.history ref — only updated when canUndo/canRedo change. */ + private historyFlags: { canUndo: boolean; canRedo: boolean }; + private playbackUnsubscribe: (() => void) | null = null; + /** Lightweight playhead frame when no PlaybackEngine is present. */ + private _playheadFrame: TimelineFrame = toFrame(0); + /** Selection state (set of clip IDs). */ + private _selectedClipIds: ReadonlySet = new Set(); + + constructor(options: TimelineEngineOptions) { + this.options = options; + this.currentState = options.initialState; + + this.history = new HistoryStack( + options.historyLimit ?? 100, + options.compression ?? DEFAULT_COMPRESSION_POLICY, + ); + this.history.push({ + state: options.initialState, + transaction: NOOP_TRANSACTION, + }); + + this.provisional = createProvisionalManager(); + + const ppf = options.getPixelsPerFrame?.() ?? DEFAULT_PPF; + const defaultTools: ITool[] = [ + new SelectionTool(), + new RazorTool(), + new RippleTrimTool(), + new RollTrimTool(), + new SlipTool(), + new SlideTool(), + new RippleDeleteTool(), + new RippleInsertTool(), + new HandTool(), + new TransitionTool(), + new KeyframeTool(), + createZoomTool({ + onZoomChange: options.onZoomChange ?? (() => {}), + initialPixelsPerFrame: ppf, + }), + ]; + const toolMap = new Map(defaultTools.map((t) => [t.id, t])); + for (const t of options.tools ?? []) { + toolMap.set(t.id, t); + } + const allTools = [...toolMap.values()]; + const defaultToolId = options.defaultToolId ?? 'selection'; + this.toolRegistry = createRegistry(allTools, toToolId(defaultToolId)); + + this.snapManager = new SnapIndexManager(); + this.snapManager.rebuildSync(options.initialState); + + this.trackIndex = new TrackIndex(); + this.trackIndex.build(options.initialState); + + if (options.pipeline) { + this.playback = new PlaybackEngine( + options.initialState, + options.pipeline, + options.dimensions ?? { width: 1920, height: 1080 }, + options.clock ?? browserClock, + ); + this.playbackUnsubscribe = this.playback.on(() => { + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + }); + } else { + this.playback = null; + } + + this.keyboardHandler = new KeyboardHandler(this.playback ?? ({} as PlaybackEngine), { + onMarkIn: options.onMarkIn, + onMarkOut: options.onMarkOut, + getTimelineState: () => this.currentState, + }); + + this.subscribers = new Set(); + this.stableTrackIds = options.initialState.timeline.tracks.map((t) => t.id); + this.prevState = options.initialState; + const u0 = this.history.canUndo(); + const r0 = this.history.canRedo(); + this.historyFlags = { canUndo: u0, canRedo: r0 }; + this.snapshot = this.buildSnapshot(EMPTY_STATE_CHANGE); + + this.subscribe = this.subscribe.bind(this); this.getSnapshot = this.getSnapshot.bind(this); + this.dispatch = this.dispatch.bind(this); } - // ── useSyncExternalStore interface ──────────────────────────────────────── + dispatch(transaction: Transaction): DispatchResult { + const result = coreDispatch(this.currentState, transaction); + if (!result.accepted) { + return result; + } + + const change = diffStates(this.currentState, result.nextState); + this.currentState = result.nextState; + this.history.pushWithCompression( + { state: result.nextState, transaction }, + transaction, + ); + + this.trackIndex.build(result.nextState); + this.snapManager.scheduleRebuild(result.nextState); + this.playback?.updateState(result.nextState); + + const nextIds = result.nextState.timeline.tracks.map((t) => t.id); + const idsChanged = + this.stableTrackIds.length !== nextIds.length || + this.stableTrackIds.some((id, i) => id !== nextIds[i]); + if (idsChanged) { + this.stableTrackIds = nextIds; + } - subscribe(listener: () => void): () => void { - this._listeners.add(listener); - return () => { this._listeners.delete(listener); }; + this.rebuildSnapshot(change); + this.notify(); + return result; } - /** Returns same object reference until the next notify(). */ - getSnapshot(): EngineSnapshot { - return this._snapshot; + undo(): boolean { + const state = this.history.undo(); + if (state === null) return false; + this.currentState = state; + this.applyStateChange(state); + return true; } - // ── Dispatch ────────────────────────────────────────────────────────────── - - dispatch(tx: Transaction): DispatchResult { - const result = coreDispatch(this._state, tx); + redo(): boolean { + const state = this.history.redo(); + if (state === null) return false; + this.currentState = state; + this.applyStateChange(state); + return true; + } - if (result.accepted) { - this._state = result.nextState; - this._history = pushHistory(this._history, result.nextState); - this._snapshot = this.buildSnapshot(); - this.notify(); - // Snap index rebuild never blocks the render — always async - queueMicrotask(() => this.rebuildSnapIndex()); + private applyStateChange(state: TimelineState): void { + const change = diffStates(this.prevState, state); + this.prevState = state; + this.trackIndex.build(state); + this.snapManager.scheduleRebuild(state); + this.playback?.updateState(state); + const nextIds = state.timeline.tracks.map((t) => t.id); + const idsChanged = + this.stableTrackIds.length !== nextIds.length || + this.stableTrackIds.some((id, i) => id !== nextIds[i]); + if (idsChanged) { + this.stableTrackIds = nextIds; } + this.rebuildSnapshot(change); + this.notify(); + } - return result; + activateTool(toolId: string): void { + const id = toToolId(toolId); + this.toolRegistry = registryActivateTool(this.toolRegistry, id); + this._syncSelectionFromTool(); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); } - // ── Pointer routing ─────────────────────────────────────────────────────── + getActiveToolId(): string { + return this.toolRegistry.activeToolId as string; + } /** - * Forward to activeTool.onPointerDown. - * Rebuilds snapshot and notifies once — cursor may change on mousedown - * (e.g. Selection tool switches to grab cursor when clicking a clip). + * Read selection from the active tool (if it exposes getSelection) and + * sync to this._selectedClipIds. Called after every pointer event so that + * SelectionTool's internal Set is always mirrored in the engine snapshot. */ + private _syncSelectionFromTool(): void { + const tool = getActiveTool(this.toolRegistry) as unknown as { + getSelection?: () => ReadonlySet; + }; + if (typeof tool.getSelection === 'function') { + this._selectedClipIds = tool.getSelection(); + } + } + handlePointerDown(event: TimelinePointerEvent, modifiers: Modifiers): void { const ctx = this.buildToolContext(modifiers); - getActiveTool(this._registry).onPointerDown(event, ctx); - this._snapshot = this.buildSnapshot(); + getActiveTool(this.toolRegistry).onPointerDown(event, ctx); + this._syncSelectionFromTool(); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); this.notify(); } - /** - * Forward to activeTool.onPointerMove. - * Sets or clears provisional, rebuilds snapshot, calls notify() ONCE. - * rAF throttle lives in tool-router.ts — not here. - */ handlePointerMove(event: TimelinePointerEvent, modifiers: Modifiers): void { - const ctx = this.buildToolContext(modifiers); - const provisional = getActiveTool(this._registry).onPointerMove(event, ctx); - - this._provisional = provisional !== null - ? setProvisional(this._provisional, provisional) - : clearProvisional(this._provisional); - - this._snapshot = this.buildSnapshot(); - this.notify(); + const ctx = this.buildToolContext(modifiers); + const provisional = getActiveTool(this.toolRegistry).onPointerMove(event, ctx); + this.provisional = + provisional !== null + ? setProvisional(this.provisional, provisional) + : clearProvisional(this.provisional); + // NOTE: Don't sync selection on move — selection only changes on up/down. + // Rebuild snapshot with current state (doesn't re-allocate state object). + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notifyProvisional(); } - /** - * Order matters: - * 1. Clear provisional first — snapshot no longer shows ghost - * 2. Get tx from tool.onPointerUp - * 3. If tx → dispatch() — dispatch calls notify() internally - * 4. Else → notify() once — push cleared ghost to subscribers - */ handlePointerUp(event: TimelinePointerEvent, modifiers: Modifiers): void { - // Step 1 — always clear provisional before reading the tx - this._provisional = clearProvisional(this._provisional); + this.provisional = clearProvisional(this.provisional); const ctx = this.buildToolContext(modifiers); - - // Step 2 — get optional commit transaction - const tx = getActiveTool(this._registry).onPointerUp(event, ctx); - + const tx = getActiveTool(this.toolRegistry).onPointerUp(event, ctx); + this._syncSelectionFromTool(); if (tx !== null) { - // Step 3 — dispatch handles snapshot rebuild + notify internally this.dispatch(tx); } else { - // Step 4 — no tx: push the cleared-ghost state to subscribers - this._snapshot = this.buildSnapshot(); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); this.notify(); } } - /** - * Forward to activeTool.onKeyDown. - * If tx non-null: dispatch (dispatch calls notify internally). - * If null: NO notify — key events that produce no tx don't trigger re-renders. - * (Different from handlePointerUp where ghost always must be cleared.) - */ - handleKeyDown(event: TimelineKeyEvent, modifiers: Modifiers): void { + /** Option Y: cursor left timeline mid-drag — cancel tool gesture and clear provisional. */ + handlePointerLeave(_event: TimelinePointerEvent): void { + getActiveTool(this.toolRegistry).onCancel(); + this._syncSelectionFromTool(); + this.provisional = clearProvisional(this.provisional); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } + + handleKeyDown(event: TimelineKeyEvent, modifiers: Modifiers): boolean { + if (this.keyboardHandler.handleKeyDown(event)) { + return true; + } const ctx = this.buildToolContext(modifiers); - const tx = getActiveTool(this._registry).onKeyDown(event, ctx); + const tx = getActiveTool(this.toolRegistry).onKeyDown(event, ctx); if (tx !== null) { this.dispatch(tx); + return true; } + return false; } - /** keyup never changes visible state — no notify. */ handleKeyUp(event: TimelineKeyEvent, modifiers: Modifiers): void { const ctx = this.buildToolContext(modifiers); - getActiveTool(this._registry).onKeyUp(event, ctx); + getActiveTool(this.toolRegistry).onKeyUp(event, ctx); } - // ── Tool management ─────────────────────────────────────────────────────── + private buildToolContext(modifiers: Modifiers): ToolContext { + const state = this.currentState; + const ppf = this.options.getPixelsPerFrame?.() ?? DEFAULT_PPF; + let idx = this.snapManager.getIndex(); + if (idx === null) { + this.snapManager.rebuildSync(state); + idx = this.snapManager.getIndex(); + } + const snapIndex = idx ?? buildSnapIndex(state, toFrame(0)); - /** - * Switch active tool. - * registryActivate calls outgoing.onCancel() before switching. - * Rebuilds snapshot (cursor changes), notifies. - */ - activateTool(id: ToolId): void { - this._registry = registryActivate(this._registry, id); - this._snapshot = this.buildSnapshot(); - this.notify(); + return { + state, + snapIndex, + pixelsPerFrame: ppf, + modifiers, + frameAtX: (x: number): TimelineFrame => Math.round(x / ppf) as TimelineFrame, + trackAtY: (y: number): TrackId | null => { + const i = Math.floor(y / DEFAULT_TRACK_H); + const t = state.timeline.tracks[i]; + return t ? t.id : null; + }, + snap: ( + frame: TimelineFrame, + exclude?: readonly string[], + allowedTypes?: readonly SnapPointType[], + ): TimelineFrame => { + const radius = SNAP_RADIUS_PX / ppf; + const hit = nearest(snapIndex, frame, radius, exclude, allowedTypes); + return hit ? hit.frame : frame; + }, + }; + } + + private buildSnapshot(change: StateChange): EngineSnapshot { + const activeTool = getActiveTool(this.toolRegistry); + const cursor = activeTool?.getCursor(this.buildToolContext(IDLE_MODIFIERS)) ?? 'default'; + const u = this.history.canUndo(); + const r = this.history.canRedo(); + if (u !== this.historyFlags.canUndo || r !== this.historyFlags.canRedo) { + this.historyFlags = { canUndo: u, canRedo: r }; + } + const playheadState = this.playback?.getState() ?? { + ...DEFAULT_PLAYHEAD_STATE, + currentFrame: this._playheadFrame, + durationFrames: (this.currentState.timeline.duration as number) ?? 0, + fps: (this.currentState.timeline.fps as number) || 30, + }; + return { + state: this.currentState, + provisional: this.provisional.current, + activeToolId: this.toolRegistry.activeToolId as string, + canUndo: u, + canRedo: r, + history: this.historyFlags, + trackIds: this.stableTrackIds, + cursor, + playhead: playheadState, + change, + selectedClipIds: this._selectedClipIds, + }; } - // ── Playhead ────────────────────────────────────────────────────────────── + private rebuildSnapshot(change: StateChange): void { + this.snapshot = this.buildSnapshot(change); + } - /** - * Update playhead position. - * Notifies (playhead is rendered + is a snap point). - * Queues snap index rebuild. - */ - setPlayheadFrame(frame: TimelineFrame): void { - this._playhead = frame; - this._snapshot = this.buildSnapshot(); - this.notify(); - queueMicrotask(() => this.rebuildSnapIndex()); + subscribe(callback: () => void): () => void { + this.subscribers.add(callback); + return () => this.subscribers.delete(callback); + } + + getSnapshot(): EngineSnapshot { + return this.snapshot; } - // ── Zoom sync ───────────────────────────────────────────────────────────── + getSnapIndex(): SnapIndex | null { + return this.snapManager.getIndex(); + } + + private notify(): void { + this.subscribers.forEach((cb) => cb()); + } /** - * Sync internal _ppf from UI zoom state. - * Does NOT call notify() — ppf is not in EngineSnapshot. - * buildToolContext() always reads the current _ppf at event time. + * Notify only on provisional changes (during drag / pointer move). + * Currently calls notify() — future optimization: use a separate lightweight + * provisional subscriber channel so that state-only hooks (useTrackIds, + * useHistory, useMarkers) skip their selector checks during drag. */ - setPixelsPerFrame(ppf: number): void { - this._ppf = ppf; + private notifyProvisional(): void { + this.subscribers.forEach((cb) => cb()); } - // ── Undo / Redo ─────────────────────────────────────────────────────────── + get playbackEngine(): PlaybackEngine | null { + return this.playback; + } - undo(): void { - if (!historyCanUndo(this._history)) return; - this._history = historyUndo(this._history); - this._state = this._history.present; - this._snapshot = this.buildSnapshot(); - this.notify(); - queueMicrotask(() => this.rebuildSnapIndex()); + getState(): TimelineState { + return this.currentState; } - redo(): void { - if (!historyCanRedo(this._history)) return; - this._history = historyRedo(this._history); - this._state = this._history.present; - this._snapshot = this.buildSnapshot(); - this.notify(); - queueMicrotask(() => this.rebuildSnapIndex()); + /** + * Seek the playhead to a specific frame. + * Works with or without a PlaybackEngine. + */ + seekTo(frame: TimelineFrame): void { + if (this.playback) { + this.playback.seekTo(frame); + } else { + const maxFrame = (this.currentState.timeline.duration as number) - 1; + this._playheadFrame = Math.max(0, Math.min(frame as number, maxFrame)) as TimelineFrame; + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } } - // ── Private ─────────────────────────────────────────────────────────────── + /** Get the current playhead frame. */ + getPlayheadFrame(): TimelineFrame { + if (this.playback) { + return this.playback.getState().currentFrame; + } + return this._playheadFrame; + } - private buildSnapshot(): EngineSnapshot { - return { - state: this._state, - provisional: this._provisional.current, - activeToolId: this._registry.activeToolId, - cursor: getActiveTool(this._registry).getCursor(this.buildToolContext(IDLE_MODIFIERS)), - canUndo: historyCanUndo(this._history), - canRedo: historyCanRedo(this._history), - trackIds: this._state.timeline.tracks.map(t => t.id), + /** + * Write a selection set back to the active tool (if SelectionTool). + * Keeps the tool's internal Set in sync with external API calls. + */ + private _writeSelectionToTool(ids: ReadonlySet): void { + const tool = getActiveTool(this.toolRegistry) as unknown as { + clearSelection?: () => void; + getSelection?: () => ReadonlySet; }; + if (typeof tool.clearSelection === 'function') { + tool.clearSelection(); + // SelectionTool.clearSelection() clears the set; we can't add back via public API + // so we just clear — the engine's _selectedClipIds drives rendering. + } + this._selectedClipIds = ids; } - private buildToolContext(modifiers: Modifiers): ToolContext { - const ppf = this._ppf; - const idx = this._snapIndex; - const state = this._state; + /** Get the current selection (clip IDs). */ + getSelectedClipIds(): ReadonlySet { + return this._selectedClipIds; + } - return { - state, - snapIndex: idx, - pixelsPerFrame: ppf, - modifiers, - frameAtX: (x: number): TimelineFrame => - Math.round(x / ppf) as TimelineFrame, - trackAtY: (y: number): TrackId | null => { - // Linear track layout — 48px per track (Phase 2 uses measured heights) - const trackIndex = Math.floor(y / DEFAULT_TRACK_H); - const track = state.timeline.tracks[trackIndex]; - return track ? track.id : null; - }, - snap: ( - frame: TimelineFrame, - exclude?: readonly string[], - allowedTypes?: Parameters[4], - ): TimelineFrame => { - const radiusFrames = SNAP_RADIUS_PX / ppf; - const hit = nearest(idx, frame, radiusFrames, exclude, allowedTypes); - return hit ? hit.frame : frame; - }, - }; + /** Set the selection (clip IDs). */ + setSelectedClipIds(ids: ReadonlySet): void { + this._writeSelectionToTool(ids); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); } - private notify(): void { - for (const listener of this._listeners) { - listener(); + /** Toggle a clip in/out of the selection. */ + toggleClipSelection(clipId: string, multi: boolean): void { + const next = new Set(this._selectedClipIds); + if (multi) { + if (next.has(clipId)) next.delete(clipId); + else next.add(clipId); + } else { + next.clear(); + next.add(clipId); } + this._writeSelectionToTool(next); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } + + /** Clear selection. */ + clearSelection(): void { + if (this._selectedClipIds.size === 0) return; + this._writeSelectionToTool(new Set()); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); } - private rebuildSnapIndex(): void { - this._snapIndex = buildSnapIndex(this._state, this._playhead); + destroy(): void { + this.playbackUnsubscribe?.(); + this.playbackUnsubscribe = null; + this.playback?.destroy(); + this.subscribers.clear(); } } diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index 10066d8..77dac59 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -1,52 +1,54 @@ /** - * @timeline/react — hooks.ts + * @webpacked-timeline/react — hooks * - * All eight Phase 1 hooks. Every hook uses useSyncExternalStore. - * - * RULES (from adapter/HOOKS.md): - * - Never use useState to mirror engine state — useSyncExternalStore only - * - Each hook selects the minimum slice of the snapshot it needs - * - No hook imports directly from @timeline/core — all via engine or EngineSnapshot - * - useClip selector defined inline to close over `id` correctly per instance - * - * CONTEXT: - * All hooks call useTimelineContext() which throws if used outside TimelineProvider. + * Phase R Step 2: All hooks use useSyncExternalStore. Engine-as-first-arg hooks + * live in hooks/index.ts. This file provides useEngine() from context and + * context-based wrappers so existing code (useTimeline(), useTrackIds(), etc.) + * continues to work without passing engine. */ -import { useContext, useSyncExternalStore } from 'react'; +import { useContext } from 'react'; import { TimelineContext } from './TimelineProvider'; - -import type { TimelineEngine, EngineSnapshot } from './engine'; -import type { Timeline, Track, Clip, TrackId, ClipId, ToolId, ProvisionalState } from '@timeline/core'; - -// --------------------------------------------------------------------------- -// Internal context accessor +import type { TimelineEngine } from './engine'; +import type { TrackId, ClipId, ProvisionalState } from '@webpacked-timeline/core'; + +// Re-export all engine-as-first-arg hooks from hooks/index +export { + useTimeline as useTimelineWithEngine, + useTrackIds as useTrackIdsWithEngine, + useTrack as useTrackWithEngine, + useClip as useClipWithEngine, + useClips, + useMarkers, + useHistory, + useActiveToolId, + useCursor, + useProvisional as useProvisionalWithEngine, + usePlayheadFrame, + useIsPlaying, + useChange, + usePlaybackEngine, + useSelectedClipIds, +} from './hooks/index'; + +// --------------------------------------------------------------------------- +// useEngine — from context (no subscription) // --------------------------------------------------------------------------- -/** - * Internal hook — throws a descriptive error if used outside TimelineProvider. - * All public hooks call this first. - */ function useTimelineContext(): TimelineEngine { const engine = useContext(TimelineContext); if (!engine) { throw new Error( 'Timeline hooks must be used within a . ' + - 'Wrap your component tree with .', + 'Wrap your component tree with .', ); } return engine; } -// --------------------------------------------------------------------------- -// useEngine — raw engine access (no subscription, no re-render on state change) -// --------------------------------------------------------------------------- - /** * Returns the TimelineEngine instance from context. - * - * Use this when you need to call engine methods (dispatch, undo, activateTool) - * without subscribing to state changes. Does NOT cause re-renders on state updates. + * Use with Phase R hooks: useTimeline(useEngine()), etc. * * @throws If used outside TimelineProvider. */ @@ -55,187 +57,58 @@ export function useEngine(): TimelineEngine { } // --------------------------------------------------------------------------- -// useTimeline — subscribe to the top-level Timeline object +// Context-based wrappers (engine from context, then delegate to hooks/index) // --------------------------------------------------------------------------- -/** - * Returns the Timeline object (name, fps, duration, startTimecode, tracks). - * - * Re-renders when the Timeline object reference changes — i.e., when any - * timeline-level field changes (name, fps, duration) or when tracks are - * added/removed. Does NOT re-render when clip data within a track changes, - * because clip mutations replace the individual clip object, not timeline. - * - * @throws If used outside TimelineProvider. - */ -export function useTimeline(): Timeline { - const engine = useTimelineContext(); - return useSyncExternalStore( - engine.subscribe, - () => engine.getSnapshot().state.timeline, - ); -} +import { + useTimeline as useTimelineFromIndex, + useTrackIds as useTrackIdsFromIndex, + useTrack as useTrackFromIndex, + useClip as useClipFromIndex, + useActiveToolId as useActiveToolIdFromIndex, + useCursor as useCursorFromIndex, + useProvisional as useProvisionalFromIndex, + useHistory as useHistoryFromIndex, +} from './hooks/index'; -// --------------------------------------------------------------------------- -// useTrackIds — stable array reference for the track list -// --------------------------------------------------------------------------- - -/** - * Returns a stable `readonly TrackId[]` that changes reference only when - * tracks are added or removed. - * - * The array is built once inside buildSnapshot() and cached until the next - * notify(). Calling .map() here would create a new array on every selector - * call, causing an infinite re-render loop via useSyncExternalStore. - * - * @throws If used outside TimelineProvider. - */ -export function useTrackIds(): readonly TrackId[] { - const engine = useTimelineContext(); - return useSyncExternalStore( - engine.subscribe, - () => engine.getSnapshot().trackIds, - ); +export function useTimeline(): ReturnType { + return useTimelineFromIndex(useTimelineContext()); } -// --------------------------------------------------------------------------- -// useTrack — subscribe to a specific track -// --------------------------------------------------------------------------- - -/** - * Returns the Track with the given id, or undefined if not found. - * - * Re-renders when that track's data changes (including its clips[]). - * Does NOT re-render when other tracks change. - * - * @throws If used outside TimelineProvider. - */ -export function useTrack(id: TrackId): Track | undefined { - const engine = useTimelineContext(); - return useSyncExternalStore( - engine.subscribe, - () => engine.getSnapshot().state.timeline.tracks.find(t => t.id === id), - ); +export function useTrackIds(): ReturnType { + return useTrackIdsFromIndex(useTimelineContext()); } -// --------------------------------------------------------------------------- -// useClip — provisional-aware clip selector -// --------------------------------------------------------------------------- - -/** - * Returns the Clip with the given id, or undefined if the clip has been deleted. - * - * Checks provisional state first (ghost clip during drag), committed state second. - * Re-renders when that clip's committed OR provisional data changes. - * Does NOT re-render when other clips change. - * - * IMPORTANT: Always handle the undefined case — the clip may be deleted mid-drag: - * const clip = useClip(id) - * if (!clip) return null ← required, not optional - * - * @throws If used outside TimelineProvider. - */ -export function useClip(id: ClipId): Clip | undefined { - const engine = useTimelineContext(); - return useSyncExternalStore( - engine.subscribe, - (): Clip | undefined => { - const snap = engine.getSnapshot(); - - // Priority 1 — provisional ghost (clip being dragged) - if (snap.provisional !== null) { - const ghost = snap.provisional.clips.find(c => c.id === id); - if (ghost) return ghost; - } - - // Priority 2 — committed state - for (const track of snap.state.timeline.tracks) { - const clip = track.clips.find(c => c.id === id); - if (clip) return clip; - } - - // Priority 3 — absent from both (deleted) - return undefined; - }, - ); +export function useTrack(id: TrackId | string): ReturnType { + return useTrackFromIndex(useTimelineContext(), id); } -// --------------------------------------------------------------------------- -// useActiveTool — current tool id + display cursor -// --------------------------------------------------------------------------- +export function useClip(id: ClipId | string): ReturnType { + return useClipFromIndex(useTimelineContext(), id); +} -/** - * Returns the active tool id and the display cursor string. - * - * `cursor` is computed at snapshot time using idle modifiers (all false). - * It reflects the cursor the tool wants to show when no keys are held - * and no drag is in progress. - * - * Re-renders when the engine notifies (tool switch, pointer down, etc.). - * - * @throws If used outside TimelineProvider. - */ -export function useActiveTool(): { readonly id: ToolId; readonly cursor: string } { +/** Returns { id, cursor }. Use useActiveToolId(engine) / useCursor(engine) for separate subs. */ +export function useActiveTool(): { readonly id: string; readonly cursor: string } { const engine = useTimelineContext(); - const snap = useSyncExternalStore(engine.subscribe, engine.getSnapshot); - return { id: snap.activeToolId, cursor: snap.cursor }; + const id = useActiveToolIdFromIndex(engine); + const cursor = useCursorFromIndex(engine); + return { id, cursor }; } -// --------------------------------------------------------------------------- -// useCanUndo / useCanRedo -// --------------------------------------------------------------------------- - -/** - * Returns true when there is a committed transaction available to undo. - * - * @throws If used outside TimelineProvider. - */ export function useCanUndo(): boolean { - const engine = useTimelineContext(); - return useSyncExternalStore( - engine.subscribe, - () => engine.getSnapshot().canUndo, - ); + return useHistoryFromIndex(useTimelineContext()).canUndo; } -/** - * Returns true when there is an undone transaction available to redo. - * - * @throws If used outside TimelineProvider. - */ export function useCanRedo(): boolean { - const engine = useTimelineContext(); - return useSyncExternalStore( - engine.subscribe, - () => engine.getSnapshot().canRedo, - ); + return useHistoryFromIndex(useTimelineContext()).canRedo; } -// --------------------------------------------------------------------------- -// useProvisional — provisional ghost state -// --------------------------------------------------------------------------- - -/** - * Returns the current ProvisionalState, or null when not dragging. - * - * This is the raw provisional state — use useClip() instead if you - * want a single clip resolved from committed + provisional. - * - * Re-renders on every pointer move during a drag (hot path). - * Use only in components that directly render ghost overlays. - * - * @throws If used outside TimelineProvider. - */ export function useProvisional(): ProvisionalState | null { - const engine = useTimelineContext(); - return useSyncExternalStore( - engine.subscribe, - () => engine.getSnapshot().provisional, - ); + return useProvisionalFromIndex(useTimelineContext()); } // --------------------------------------------------------------------------- -// Phase 6 Step 6: Playhead hooks (PlaybackEngine, not TimelineEngine) +// Phase 6 PlaybackEngine hooks (take PlaybackEngine, not TimelineEngine) // --------------------------------------------------------------------------- export { usePlayhead } from './hooks/usePlayhead'; @@ -243,7 +116,7 @@ export type { UsePlayheadResult } from './hooks/usePlayhead'; export { usePlayheadEvent } from './hooks/usePlayheadEvent'; // --------------------------------------------------------------------------- -// Re-export EngineSnapshot type for consumers +// Types // --------------------------------------------------------------------------- -export type { EngineSnapshot }; +export type { EngineSnapshot } from './engine'; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts new file mode 100644 index 0000000..b6029f9 --- /dev/null +++ b/packages/react/src/hooks/index.ts @@ -0,0 +1,246 @@ +/** + * Phase R Step 2 — Full hook set + * + * All hooks take engine: TimelineEngine as first arg and use useSyncExternalStore + * with a pure selector. Referential stability of the returned value controls re-renders. + */ + +import { useSyncExternalStore } from 'react'; +import type { TimelineEngine } from '../engine'; +import type { EngineSnapshot } from '../types/engine-snapshot'; +import type { Timeline, Track, Clip } from '@webpacked-timeline/core'; +import type { TrackId, ClipId } from '@webpacked-timeline/core'; +import type { TimelineFrame, ProvisionalState, StateChange } from '@webpacked-timeline/core'; + +type Marker = Timeline['markers'][number]; + +const EMPTY_CLIPS: readonly Clip[] = []; +const EMPTY_MARKERS: readonly Marker[] = []; + +function getServerSnapshot(engine: TimelineEngine, selector: (snap: EngineSnapshot) => T): T { + return selector(engine.getSnapshot()); +} + +// --------------------------------------------------------------------------- +// useTimeline +// --------------------------------------------------------------------------- + +export function useTimeline(engine: TimelineEngine): Timeline { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().state.timeline, + () => getServerSnapshot(engine, (snap) => snap.state.timeline), + ); +} + +// --------------------------------------------------------------------------- +// useTrackIds +// --------------------------------------------------------------------------- + +export function useTrackIds(engine: TimelineEngine): readonly string[] { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().trackIds, + () => getServerSnapshot(engine, (snap) => snap.trackIds), + ); +} + +// --------------------------------------------------------------------------- +// useTrack +// --------------------------------------------------------------------------- + +export function useTrack(engine: TimelineEngine, trackId: TrackId | string): Track | null { + const id = typeof trackId === 'string' ? trackId : (trackId as string); + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().state.timeline.tracks.find((t) => t.id === id) ?? null, + () => + getServerSnapshot( + engine, + (snap) => snap.state.timeline.tracks.find((t) => t.id === id) ?? null, + ), + ); +} + +// --------------------------------------------------------------------------- +// useClip — provisional-aware; isolation: clip A change does not re-render clip B +// --------------------------------------------------------------------------- + +export function useClip(engine: TimelineEngine, clipId: ClipId | string): Clip | null { + const id = typeof clipId === 'string' ? clipId : (clipId as string); + return useSyncExternalStore( + engine.subscribe, + () => { + const snap = engine.getSnapshot(); + if (snap.provisional !== null) { + const ghost = snap.provisional.clips.find((c) => c.id === id); + if (ghost) return ghost; + } + for (const track of snap.state.timeline.tracks) { + const clip = track.clips.find((c) => c.id === id); + if (clip) return clip; + } + return null; + }, + () => { + const snap = engine.getSnapshot(); + if (snap.provisional !== null) { + const ghost = snap.provisional.clips.find((c) => c.id === id); + if (ghost) return ghost; + } + for (const track of snap.state.timeline.tracks) { + const clip = track.clips.find((c) => c.id === id); + if (clip) return clip; + } + return null; + }, + ); +} + +// --------------------------------------------------------------------------- +// useClips +// --------------------------------------------------------------------------- + +export function useClips( + engine: TimelineEngine, + trackId: TrackId | string, +): readonly Clip[] { + const id = typeof trackId === 'string' ? trackId : (trackId as string); + return useSyncExternalStore( + engine.subscribe, + () => + engine.getSnapshot().state.timeline.tracks.find((t) => t.id === id)?.clips ?? EMPTY_CLIPS, + () => + getServerSnapshot( + engine, + (snap) => snap.state.timeline.tracks.find((t) => t.id === id)?.clips ?? EMPTY_CLIPS, + ), + ); +} + +// --------------------------------------------------------------------------- +// useMarkers +// --------------------------------------------------------------------------- + +export function useMarkers(engine: TimelineEngine): readonly Marker[] { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().state.timeline.markers ?? EMPTY_MARKERS, + () => + getServerSnapshot( + engine, + (snap) => snap.state.timeline.markers ?? EMPTY_MARKERS, + ), + ); +} + +// --------------------------------------------------------------------------- +// useHistory — stable object ref when canUndo/canRedo unchanged +// --------------------------------------------------------------------------- + +export function useHistory(engine: TimelineEngine): { + canUndo: boolean; + canRedo: boolean; +} { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().history, + () => getServerSnapshot(engine, (snap) => snap.history), + ); +} + +// --------------------------------------------------------------------------- +// useActiveToolId +// --------------------------------------------------------------------------- + +export function useActiveToolId(engine: TimelineEngine): string { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().activeToolId, + () => getServerSnapshot(engine, (snap) => snap.activeToolId), + ); +} + +// --------------------------------------------------------------------------- +// useCursor +// --------------------------------------------------------------------------- + +export function useCursor(engine: TimelineEngine): string { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().cursor, + () => getServerSnapshot(engine, (snap) => snap.cursor), + ); +} + +// --------------------------------------------------------------------------- +// useProvisional +// --------------------------------------------------------------------------- + +export function useProvisional(engine: TimelineEngine): ProvisionalState | null { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().provisional, + () => getServerSnapshot(engine, (snap) => snap.provisional), + ); +} + +// --------------------------------------------------------------------------- +// usePlayheadFrame — re-renders every frame during playback +// --------------------------------------------------------------------------- + +export function usePlayheadFrame(engine: TimelineEngine): TimelineFrame { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().playhead.currentFrame, + () => getServerSnapshot(engine, (snap) => snap.playhead.currentFrame), + ); +} + +// --------------------------------------------------------------------------- +// useIsPlaying +// --------------------------------------------------------------------------- + +export function useIsPlaying(engine: TimelineEngine): boolean { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().playhead.isPlaying, + () => getServerSnapshot(engine, (snap) => snap.playhead.isPlaying), + ); +} + +// --------------------------------------------------------------------------- +// useChange — advanced: subscribe to diff for custom bailout +// --------------------------------------------------------------------------- + +export function useChange(engine: TimelineEngine): StateChange { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().change, + () => getServerSnapshot(engine, (snap) => snap.change), + ); +} + +// --------------------------------------------------------------------------- +// usePlaybackEngine +// --------------------------------------------------------------------------- + +import type { PlaybackEngine } from '@webpacked-timeline/core'; + +export function usePlaybackEngine(engine: TimelineEngine): PlaybackEngine | null { + return engine.playbackEngine; +} + +// --------------------------------------------------------------------------- +// useSelectedClipIds — re-renders when selection changes +// --------------------------------------------------------------------------- + +const EMPTY_SELECTION: ReadonlySet = new Set(); + +export function useSelectedClipIds(engine: TimelineEngine): ReadonlySet { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().selectedClipIds ?? EMPTY_SELECTION, + () => getServerSnapshot(engine, (snap) => snap.selectedClipIds ?? EMPTY_SELECTION), + ); +} diff --git a/packages/react/src/hooks/use-tool-router.ts b/packages/react/src/hooks/use-tool-router.ts new file mode 100644 index 0000000..67fc7a8 --- /dev/null +++ b/packages/react/src/hooks/use-tool-router.ts @@ -0,0 +1,29 @@ +/** + * useToolRouter — Phase R Step 3 + * + * Returns stable ToolRouterHandlers from createToolRouter. + * Recreate only when engine changes. + */ + +import { useMemo } from 'react'; +import type { TimelineEngine } from '../engine'; +import { createToolRouter } from '../adapter/tool-router'; +import type { ToolRouterHandlers } from '../adapter/tool-router'; + +export function useToolRouter( + engine: TimelineEngine, + options: { + getPixelsPerFrame: () => number; + getScrollLeft?: () => number; + }, +): ToolRouterHandlers { + return useMemo( + () => + createToolRouter({ + engine, + getPixelsPerFrame: options.getPixelsPerFrame, + getScrollLeft: options.getScrollLeft, + }), + [engine], + ); +} diff --git a/packages/react/src/hooks/use-virtual-window.ts b/packages/react/src/hooks/use-virtual-window.ts new file mode 100644 index 0000000..7d0ccb6 --- /dev/null +++ b/packages/react/src/hooks/use-virtual-window.ts @@ -0,0 +1,62 @@ +/** + * Virtual rendering hooks — Phase R Step 3 + * + * useVirtualWindow: derived from viewport/scroll/zoom (useMemo). + * useVisibleClips: subscribes to engine and returns clips in window (useSyncExternalStore). + * Result is cached so same (state, window) returns same reference (avoids infinite loop). + */ + +import { useMemo, useSyncExternalStore, useRef } from 'react'; +import type { TimelineEngine } from '../engine'; +import type { VirtualWindow, VirtualClipEntry, TimelineState } from '@webpacked-timeline/core'; +import { getVisibleFrameRange, getVisibleClips } from '@webpacked-timeline/core'; + +export function useVirtualWindow( + _engine: TimelineEngine, + viewportWidth: number, + scrollLeft: number, + pixelsPerFrame: number, +): VirtualWindow { + return useMemo( + () => getVisibleFrameRange(viewportWidth, scrollLeft, pixelsPerFrame), + [viewportWidth, scrollLeft, pixelsPerFrame], + ); +} + +function getVisibleClipsCached( + state: TimelineState, + window: VirtualWindow, + cache: { state: TimelineState | null; window: VirtualWindow | null; result: VirtualClipEntry[] }, +): VirtualClipEntry[] { + if (cache.state === state && cache.window === window) return cache.result; + cache.state = state; + cache.window = window; + cache.result = getVisibleClips(state, window); + return cache.result; +} + +export function useVisibleClips( + engine: TimelineEngine, + window: VirtualWindow, +): VirtualClipEntry[] { + const cache = useRef<{ + state: TimelineState | null; + window: VirtualWindow | null; + result: VirtualClipEntry[]; + }>({ state: null, window: null, result: [] }); + return useSyncExternalStore( + engine.subscribe, + () => + getVisibleClipsCached( + engine.getSnapshot().state, + window, + cache.current, + ), + () => + getVisibleClipsCached( + engine.getSnapshot().state, + window, + cache.current, + ), + ); +} diff --git a/packages/react/src/hooks/usePlayhead.ts b/packages/react/src/hooks/usePlayhead.ts index c6200cb..e6efa1a 100644 --- a/packages/react/src/hooks/usePlayhead.ts +++ b/packages/react/src/hooks/usePlayhead.ts @@ -14,7 +14,7 @@ import type { PlaybackRate, PlaybackQuality, LoopRegion, -} from '@timeline/core'; +} from '@webpacked-timeline/core'; export type UsePlayheadResult = { currentFrame: TimelineFrame; diff --git a/packages/react/src/hooks/usePlayheadEvent.ts b/packages/react/src/hooks/usePlayheadEvent.ts index bf6a2d6..a27b6f8 100644 --- a/packages/react/src/hooks/usePlayheadEvent.ts +++ b/packages/react/src/hooks/usePlayheadEvent.ts @@ -12,7 +12,7 @@ import type { PlayheadEventType, PlayheadListener, PlayheadEvent, -} from '@timeline/core'; +} from '@webpacked-timeline/core'; export function usePlayheadEvent( engine: PlaybackEngine, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index bc8a1ad..615a720 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,13 +1,13 @@ /** - * @timeline/react + * @webpacked-timeline/react * - * React adapter for @timeline/core. Provides the TimelineProvider context + * React adapter for @webpacked-timeline/core. Provides the TimelineProvider context * and all Phase 1 hooks for subscribing to engine state. * * @example * ```tsx - * import { TimelineEngine, createTimeline, createTimelineState, toFrame, frameRate } from '@timeline/core'; - * import { TimelineProvider, useTimeline, useTrackIds } from '@timeline/react'; + * import { TimelineEngine, createTimeline, createTimelineState, toFrame, frameRate } from '@webpacked-timeline/core'; + * import { TimelineProvider, useTimeline, useTrackIds } from '@webpacked-timeline/react'; * * const engine = new TimelineEngine(createTimelineState({ * timeline: createTimeline({ id: 'tl-1', name: 'My Timeline', fps: frameRate(30), duration: toFrame(9000) }), @@ -30,21 +30,42 @@ export { TimelineProvider, TimelineContext } from './TimelineProvider'; export type { TimelineProviderProps } from './TimelineProvider'; // Engine class + snapshot type -export { TimelineEngine } from './engine'; -export type { EngineSnapshot } from './engine'; +export { TimelineEngine, DEFAULT_PLAYHEAD_STATE } from './engine'; +export type { EngineSnapshot, TimelineEngineOptions } from './engine'; -// All Phase 1 hooks + Phase 6 playhead hooks (single source of truth: hooks.ts) +// Phase R Step 2: all hooks (context-based wrappers + engine-as-first-arg from hooks/index) export { useEngine, useTimeline, useTrackIds, useTrack, useClip, + useClips, + useMarkers, + useHistory, useActiveTool, + useActiveToolId, useCanUndo, useCanRedo, + useCursor, useProvisional, + usePlayheadFrame, + useIsPlaying, + useChange, + usePlaybackEngine, usePlayhead, usePlayheadEvent, + useTimelineWithEngine, + useTrackIdsWithEngine, + useTrackWithEngine, + useClipWithEngine, + useProvisionalWithEngine, + useSelectedClipIds, } from './hooks'; export type { UsePlayheadResult } from './hooks'; + +// Phase R Step 3: ToolRouter (adapter) + virtual hooks +export { createToolRouter } from './adapter/tool-router'; +export type { ToolRouterOptions, ToolRouterHandlers } from './adapter/tool-router'; +export { useToolRouter } from './hooks/use-tool-router'; +export { useVirtualWindow, useVisibleClips } from './hooks/use-virtual-window'; diff --git a/packages/react/src/tool-router.ts b/packages/react/src/tool-router.ts index 744b1a0..34d84d3 100644 --- a/packages/react/src/tool-router.ts +++ b/packages/react/src/tool-router.ts @@ -26,7 +26,7 @@ import type { ClipId, TimelineFrame, TimelineState, -} from '@timeline/core'; +} from '@webpacked-timeline/core'; // --------------------------------------------------------------------------- // Public types @@ -164,6 +164,8 @@ function convertKeyEvent(e: KeyboardEvent): TimelineKeyEvent { shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + repeat: e.repeat, }; } diff --git a/packages/react/src/types/engine-snapshot.ts b/packages/react/src/types/engine-snapshot.ts new file mode 100644 index 0000000..f349fb4 --- /dev/null +++ b/packages/react/src/types/engine-snapshot.ts @@ -0,0 +1,64 @@ +/** + * EngineSnapshot — Phase R Step 1 + * + * The single object useSyncExternalStore reads. + * Includes edit state, playback state (merged), and change for hook optimization. + */ + +import type { + TimelineState, + ProvisionalState, + StateChange, + PlayheadState, + PipelineConfig, + Clock, + CompressionPolicy, + ITool, + TimelineFrame, +} from '@webpacked-timeline/core'; +import { toFrame } from '@webpacked-timeline/core'; + +export type TimelineEngineOptions = { + initialState: TimelineState; + pipeline?: PipelineConfig; + dimensions?: { width: number; height: number }; + clock?: Clock; + historyLimit?: number; + compression?: CompressionPolicy; + /** Merged with defaults; duplicate ids override. */ + tools?: ITool[]; + /** Default active tool id (default: 'selection'). */ + defaultToolId?: string; + onMarkIn?: (frame: TimelineFrame) => void; + onMarkOut?: (frame: TimelineFrame) => void; + onZoomChange?: (pixelsPerFrame: number) => void; + getPixelsPerFrame?: () => number; +}; + +export type EngineSnapshot = { + readonly state: TimelineState; + readonly provisional: ProvisionalState | null; + readonly activeToolId: string; + readonly canUndo: boolean; + readonly canRedo: boolean; + /** Stable ref for useHistory — only changes when canUndo/canRedo change. */ + readonly history: { readonly canUndo: boolean; readonly canRedo: boolean }; + readonly trackIds: readonly string[]; + readonly cursor: string; + readonly playhead: PlayheadState; + readonly change: StateChange; + /** Currently selected clip IDs. */ + readonly selectedClipIds: ReadonlySet; +}; + +export const DEFAULT_PLAYHEAD_STATE: PlayheadState = { + currentFrame: toFrame(0), + isPlaying: false, + playbackRate: 1.0, + quality: 'full', + durationFrames: 0, + fps: 30, + loopRegion: null, + prerollFrames: 0, + postrollFrames: 0, +}; diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index c372e21..675b788 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -27,5 +27,5 @@ "rootDir": "./src" }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx", "examples"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx", "examples", "**/App.tsx"] } diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md new file mode 100644 index 0000000..3c6ce45 --- /dev/null +++ b/packages/ui/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to `@webpacked-timeline/ui` are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.0.0-beta.1] - 2026-03-07 + +### Added +- DaVinci Resolve-style preset with 6 components: + - `DaVinciEditor` — full-layout editor (toolbar + ruler + tracks + clips + playhead) + - `DaVinciToolbar` — tool buttons, zoom controls, undo/redo, play/pause + - `DaVinciRuler` — timecode ruler with major/minor tick marks + - `DaVinciTrack` — track label row with name, type badge, lock/visibility, solo/mute (audio), clip count + - `DaVinciClip` — clip block with waveform visualization, label, trim handles, accent strip + - `DaVinciPlayhead` — red playhead line +- `TimelineProvider` context and `useTimelineContext` / `useEngine` for custom layouts +- Shared utilities: `frameToPx`, `pxToFrame`, `frameToTimecode`, `rulerTickInterval`, `clamp`, `cn` +- CSS variable theming system with ~50 tokens in `tokens.css` +- DaVinci dark theme override in `davinci.css` +- Style entry points: `@webpacked-timeline/ui/styles/davinci` and `@webpacked-timeline/ui/styles/tokens` +- Full keyboard shortcut support (V/C/T/R/S/Y/H for tools, Space for play, arrow keys for scrubbing, Cmd+Z for undo) +- Track resize (drag handle between tracks) +- Clip selection, multi-select (Cmd+A), and deletion (Delete/Backspace) +- Virtual windowing for clips outside viewport +- Snap indicator lines during drag operations +- Add/delete tracks from the label column +- Add clips to tracks +- Zoom slider with logarithmic scale +- Playhead auto-scroll during playback +- Hand tool for panning +- Tabler-based SVG icon set diff --git a/packages/ui/README.md b/packages/ui/README.md index 0b7ca30..35f5045 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,130 +1,138 @@ -# @timeline/ui +# @webpacked-timeline/ui -Installable UI components for timeline infrastructure. +DaVinci-style React timeline editor. One import. Full professional timeline. -## Installation +## Install ```bash -npm install @timeline/ui @timeline/core @timeline/react +npm install @webpacked-timeline/ui @webpacked-timeline/react @webpacked-timeline/core ``` -### Requirements +## Quick Start (30 seconds) -This package requires Tailwind CSS to be installed and configured in your project: - -```bash -npm install -D tailwindcss +```tsx +import { DaVinciEditor } from '@webpacked-timeline/ui'; +import '@webpacked-timeline/ui/styles/davinci'; +import { TimelineEngine } from '@webpacked-timeline/react'; +import { createTimelineState, createTimeline, toFrame, frameRate } from '@webpacked-timeline/core'; + +const engine = new TimelineEngine({ + initialState: createTimelineState({ + timeline: createTimeline({ + id: 'tl-1', + name: 'My Timeline', + fps: frameRate(30), + duration: toFrame(9000), + }), + }), +}); + +export default function App() { + return ; +} ``` -## Tailwind CSS Setup - -The timeline UI components use Tailwind CSS utility classes. You need to configure Tailwind in your project: - -### 1. Use the provided preset (Recommended) +That's it — a full DaVinci Resolve-style timeline editor with toolbar, ruler, tracks, clips, playhead, undo/redo, and keyboard shortcuts. -Create or update your `tailwind.config.js`: - -```js -/** @type {import('tailwindcss').Config} */ -module.exports = { - presets: [ - require('@timeline/ui/tailwind.config.js') - ], - content: [ - './src/**/*.{js,ts,jsx,tsx}', - './node_modules/@timeline/ui/dist/**/*.{js,ts,jsx,tsx}', - ], - // ... your custom theme extensions -} -``` +## Components -### 2. Manual setup - -Alternatively, ensure your Tailwind config includes the timeline UI package in the content array: - -```js -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: [ - './src/**/*.{js,ts,jsx,tsx}', - './node_modules/@timeline/ui/dist/**/*.{js,ts,jsx,tsx}', - ], - theme: { - extend: { - // The UI components primarily use the zinc color palette - }, - }, +All components are exported from the package root: + +| Component | Description | +|-----------|-------------| +| `DaVinciEditor` | Full-layout editor (toolbar + ruler + tracks + playhead) | +| `DaVinciToolbar` | Tool buttons, zoom controls, transport (undo/redo/play) | +| `DaVinciRuler` | Timecode ruler with major/minor ticks | +| `DaVinciTrack` | Track label row (name, type badge, lock, solo/mute) | +| `DaVinciClip` | Clip block with waveform, label, trim handles | +| `DaVinciPlayhead` | Red playhead line | + +### DaVinciEditor Props + +```typescript +interface DaVinciEditorProps { + engine: TimelineEngine; // from @webpacked-timeline/react + initialPpf?: number; // initial pixels per frame (default: 4) + onPpfChange?: (ppf: number) => void; + registerZoomHandler?: (handler: (ppf: number) => void) => void; + className?: string; + style?: React.CSSProperties; } ``` -### 3. Import Tailwind styles +### Context & Utilities -Make sure to import Tailwind's base styles in your main CSS file: +For custom layouts, use the context directly: -```css -@tailwind base; -@tailwind components; -@tailwind utilities; +```tsx +import { TimelineProvider, useTimelineContext, useEngine } from '@webpacked-timeline/ui'; +import { frameToPx, pxToFrame, frameToTimecode } from '@webpacked-timeline/ui'; ``` -## Components - -- `Timeline` - Root timeline container with built-in UI state management -- `Track` - Single track row -- `Clip` - Individual clip block -- `TimeRuler` - Timeline ruler with frame markers +## Theming -## Usage +All visual properties are controlled by CSS custom properties. Import the DaVinci theme: -```tsx -import { Timeline } from "@timeline/ui"; -import { TimelineProvider } from "@timeline/react"; -import { TimelineEngine } from "@timeline/core"; - -// Create your timeline engine -const engine = new TimelineEngine(initialState); - -function App() { - return ( - - - - ); -} +```css +@import '@webpacked-timeline/ui/styles/davinci'; ``` -## UI State Management - -The `Timeline` component includes built-in UI state management via `TimelineUIContext`. You can access and control the UI state from external components: +Override any token in your CSS: -```tsx -import { useTimelineUI } from "@timeline/ui"; - -function CustomTransportBar() { - const { state, actions } = useTimelineUI(); - - return ( -
- - Playhead: {state.playhead} - Zoom: {state.zoom}x -
- ); +```css +:root { + --tl-clip-video-bg: hsl(270 70% 50%); + --tl-track-height: 60px; + --tl-playhead-color: hsl(120 60% 50%); } ``` -## Styling - -Components use Tailwind CSS classes. You can override styles via the `className` prop: - -```tsx - -``` +### Key Tokens + +| Token | Default | Description | +|-------|---------|-------------| +| `--tl-app-bg` | `hsl(220 13% 9%)` | App background | +| `--tl-panel-bg` | `hsl(220 13% 11%)` | Panel background | +| `--tl-toolbar-bg` | `hsl(220 13% 11%)` | Toolbar background | +| `--tl-toolbar-height` | `40px` | Toolbar height | +| `--tl-ruler-height` | `32px` | Ruler height | +| `--tl-track-height` | `80px` | Track row height | +| `--tl-track-bg-video` | `#28282E` | Video track background | +| `--tl-track-bg-audio` | `#28282E` | Audio track background | +| `--tl-clip-video-bg` | `#2E77A5` | Video clip fill | +| `--tl-clip-audio-bg` | `#179160` | Audio clip fill | +| `--tl-clip-radius` | `2px` | Clip border radius | +| `--tl-clip-text` | `hsl(0 0% 92%)` | Clip label color | +| `--tl-playhead-color` | `#ff3b30` | Playhead line color | +| `--tl-timecode-color` | `hsl(0 0% 88%)` | Timecode text color | +| `--tl-label-width` | `200px` | Track label column width | +| `--tl-snap-color` | `hsl(45 90% 60%)` | Snap indicator color | + +See [tokens.css](src/tokens.css) for the full list of ~50 tokens. All colors controlled by CSS variables — no hardcoded colors in components. + +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `V` | Selection tool | +| `C` | Razor tool | +| `T` | Ripple Trim | +| `R` | Roll Trim | +| `S` | Slip | +| `Y` | Slide | +| `H` | Hand (pan) | +| `Space` | Play/Pause | +| `←` / `→` | Step 1 frame | +| `Shift+←/→` | Step 10 frames | +| `Cmd+Z` | Undo | +| `Cmd+Shift+Z` | Redo | +| `Delete` | Delete selected clips | +| `Cmd+A` | Select all | +| `Escape` | Clear selection | + +## Presets + +The DaVinci preset ships with `@webpacked-timeline/ui`. More presets are planned. ## License diff --git a/packages/ui/docs/ARCHITECTURE.md b/packages/ui/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3ae89f0 --- /dev/null +++ b/packages/ui/docs/ARCHITECTURE.md @@ -0,0 +1,245 @@ +We are starting Phase U. +Before any component code, create this file: + +packages/ui/ARCHITECTURE.md + +Content: + +════════════════════════════════════════════════════════ +@webpacked-timeline/ui — Architecture & Contract +════════════════════════════════════════════════════════ + +## Philosophy + +@webpacked-timeline/ui is not a component library. +It is a component distribution system. + +When you run `npx @webpacked-timeline/ui add clip`, a clip.tsx +lands in YOUR project. You own it. Edit every pixel, +every animation, every class. There are no version +conflicts, no !important fights, no "the library +doesn't support that prop." + +The only things you import from @timeline packages +are @webpacked-timeline/core (types + engine) and +@webpacked-timeline/react (hooks). Never the UI. + +## What gets copied into your project + +components/timeline/ + _shared/ ← copied first, once + time.ts ← timeToPx, pxToTime, frameToTimecode + geometry.ts ← rect math, hit testing + interaction.ts ← drag state, pointer capture + use-drag.ts ← useDrag() hook + use-snap.ts ← useSnap() hook + timeline.css ← CSS variable tokens + timeline-root.tsx ← Tier 1 + track.tsx + clip.tsx + playhead.tsx + ruler.tsx + toolbar.tsx ← Tier 2 + zoom-bar.tsx + thumbnail-strip.tsx ← Tier 3 + waveform.tsx + clip-label.tsx + effect-lane.tsx ← Tier 4 + keyframe-diamond.tsx + transition-handle.tsx + marker-pin.tsx ← Tier 5 + marker-range.tsx + in-out-handles.tsx + +## Provider pattern + +timeline-root.tsx is the ONLY component that +takes engine as a prop. It wraps children in +TimelineProvider: + + + {children} + + +All other components read engine from context: + + const engine = useTimelineEngine() + +This is not global magic. Engine is explicit at +the root. One place. Clear ownership. + +Override: any component can accept an optional +engine prop to bypass context: + + + +## Rendering contract + +Components MAY: + - Read engine state via hooks + - Call engine commands (dispatch, activateTool, + play, pause, seekTo, undo, redo) + - Render provisional state from useProvisional() + - Subscribe to playhead via usePlayheadFrame() + +Components MUST NOT: + - Modify engine state directly (no engine.state.x = y) + - Store canonical engine values in local useState + (no const [frame, setFrame] = useState(0) when + frame comes from the engine) + - Embed tool logic (tools live in core) + - Import from other copied components + (import only from _shared/ and @webpacked-timeline/*) + +## Tool system + +Tool state is managed entirely by @webpacked-timeline/core. +Components respect tool mode — they do not define it. + +engine.activateTool('razor') // switch tool +engine.getActiveToolId() // read active tool +useActiveToolId(engine) // React hook + +The timeline-root component attaches the ToolRouter +(from @webpacked-timeline/react) to its DOM container. +All pointer events flow: DOM → ToolRouter → ITool. +Components never handle pointer events directly +for tool purposes. + +## CSS variable tokens + +All visual values are CSS variables defined in +timeline.css. No hardcoded colors anywhere. + +Token naming: --tl-{component}-{property}-{state} + +Examples: + --tl-clip-bg + --tl-clip-bg-selected + --tl-clip-bg-provisional + --tl-track-height + --tl-playhead-color + --tl-ruler-height + --tl-waveform-color + --tl-keyframe-color + +Override any token: + :root { --tl-clip-bg: hsl(142 71% 45%); } + +## Themes + +Themes are CSS files that override token values. +Install: npx @webpacked-timeline/ui add theme --theme=dark-pro + +Available themes: + dark-pro (default, DaVinci-inspired) + light (Final Cut Pro-inspired) + high-contrast + +## Presets + +Presets are convenience installers. Not locked +bundles. Fully editable after install. + + npx @webpacked-timeline/ui add --preset=minimal + Installs: timeline-root, track, clip, + playhead, ruler + + npx @webpacked-timeline/ui add --preset=video-editor + Installs: all Tier 1–4 components + + npx @webpacked-timeline/ui add --preset=audio-editor + Installs: timeline-root, track, clip, + waveform, playhead, ruler, toolbar + +## Versioning and updates + +Copied files belong to you. The CLI never +silently overwrites them. + + npx @webpacked-timeline/ui diff clip + Shows what changed between your version + and the latest registry version. + + npx @webpacked-timeline/ui update clip + Shows diff first, then asks for confirmation. + Use --force to skip confirmation. + + npx @webpacked-timeline/ui update clip --force + Overwrites without confirmation. + +## Performance policy + +Components mount only visible clips. +useVisibleClips(engine, window) from @webpacked-timeline/react +returns VirtualClipEntry[] with isVisible flags. + +Timeline renders ALL tracks but only mounts clip +components where isVisible === true. + +Future (Phase P): full windowed virtualization +using @webpacked-timeline/core TrackIndex for O(log n) lookup. + +## Accessibility policy + +Minimum viable accessibility shipped with v0.1: + - Keyboard scrubbing (arrow keys via KeyboardHandler) + - Focus ring tokens (--tl-focus-ring) + - ARIA role="region" on timeline root + - ARIA role="listitem" on tracks + - aria-label on playhead + +Full accessibility (WCAG 2.1 AA) is a roadmap item. + +## Non-goals for v0.x + +These are explicitly out of scope: + - Built-in video rendering pipeline + - Cloud export or storage + - Asset management backend + - Real-time collaboration layer + - Mobile touch support (pointer events only) + - Accessibility beyond minimum viable + - Server-side rendering support + +## Component anatomy + +Every component follows this structure: + + 1. Imports (@webpacked-timeline/react, _shared/, react) + 2. Props interface (documented with JSDoc) + 3. Component function + 4. Internal sub-components (if needed, in same file) + 5. Export + +Props interface minimum: + className?: string + style?: React.CSSProperties + +Render prop escape hatches where content varies: + renderLabel?: (clip: Clip) => React.ReactNode + renderThumbnail?: (clip: Clip) => React.ReactNode + renderOverlay?: (clip: Clip) => React.ReactNode + +## Testing policy + +CLI: unit tests for registry, file copier, + dependency resolver, diff engine + +Registry: integrity test — every registered + component's files exist and compile + +Components: render tests using + @testing-library/react + +Integration: example project in + packages/ui/examples/ + that installs components and renders + +════════════════════════════════════════════════════════ +ACCEPTANCE +════════════════════════════════════════════════════════ +- Create packages/ui/ARCHITECTURE.md +- No code changes +- No test changes +- pnpm exec tsc --noEmit → 0 errors \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json index c65e719..4a724f0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,46 +1,51 @@ { - "name": "@timeline/ui", - "version": "0.1.0", - "description": "Installable UI components for timeline infrastructure", + "name": "@webpacked-timeline/ui", + "version": "1.0.0-beta.1", + "description": "DaVinci-style React timeline UI. Drop-in components built on @webpacked-timeline/core and @webpacked-timeline/react.", "type": "module", - "main": "./dist/index.js", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", + "require": "./dist/index.cjs", "types": "./dist/index.d.ts" - } + }, + "./styles/davinci": "./dist/davinci.css", + "./styles/tokens": "./dist/tokens.css" }, - "files": [ - "dist", - "src", - "tailwind.config.js" - ], + "files": ["dist", "README.md", "CHANGELOG.md"], "scripts": { - "build": "tsc", - "dev": "tsc --watch", - "clean": "rm -rf dist", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage" - }, - "dependencies": { - "@timeline/core": "workspace:*", - "@timeline/react": "workspace:*" + "build": "vite build && tsc --emitDeclarationOnly --outDir dist && cp src/tokens.css src/davinci.css dist/", + "dev": "vite build --watch", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" }, "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0", - "tailwindcss": "^3.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "@webpacked-timeline/core": ">=1.0.0-beta.1", + "@webpacked-timeline/react": ">=1.0.0-beta.1" }, "devDependencies": { - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.1.0", + "@webpacked-timeline/core": "workspace:*", + "@webpacked-timeline/react": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", - "@vitest/coverage-v8": "^2.1.8", - "jsdom": "^25.0.1", + "@types/node": "^20.0.0", "typescript": "^5.0.0", - "vitest": "^2.1.8" - } + "vite": "^5.0.0" + }, + "keywords": ["timeline", "react", "nle", "davinci", "video-editor", "ui", "components", "typescript"], + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/manas-timeline/timeline", + "directory": "packages/ui" + }, + "homepage": "https://github.com/manas-timeline/timeline/tree/main/packages/ui#readme" } diff --git a/packages/ui/src/__tests__/setup.ts b/packages/ui/src/__tests__/setup.ts deleted file mode 100644 index 7b0828b..0000000 --- a/packages/ui/src/__tests__/setup.ts +++ /dev/null @@ -1 +0,0 @@ -import '@testing-library/jest-dom'; diff --git a/packages/ui/src/context/TimelineUIContext.tsx b/packages/ui/src/context/TimelineUIContext.tsx deleted file mode 100644 index 7caaf7a..0000000 --- a/packages/ui/src/context/TimelineUIContext.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { createContext, useContext, useState, ReactNode } from 'react'; -import { frame, type Frame } from '@timeline/core'; - -/** - * UI Interaction State - * - * Separate from engine state - this is purely for UI interactions. - * Does NOT integrate with undo/redo. - */ -export interface TimelineUIState { - /** Current playhead position */ - playhead: Frame; - - /** Zoom level (pixels per frame) */ - zoom: number; - - /** Whether snapping is enabled */ - snappingEnabled: boolean; - - /** Current editing mode */ - editingMode: 'normal' | 'ripple' | 'insert'; - - /** Set of selected clip IDs */ - selectedClipIds: Set; -} - -/** - * UI Context Actions - */ -export interface TimelineUIActions { - setPlayhead: (playhead: Frame) => void; - setZoom: (zoom: number) => void; - setSnappingEnabled: (enabled: boolean) => void; - setEditingMode: (mode: 'normal' | 'ripple' | 'insert') => void; - setSelectedClipIds: (clipIds: Set) => void; - toggleClipSelection: (clipId: string) => void; - clearSelection: () => void; -} - -/** - * Combined UI Context - */ -export interface TimelineUIContextValue { - state: TimelineUIState; - actions: TimelineUIActions; -} - -const TimelineUIContext = createContext(null); - -/** - * TimelineUI Provider Props - */ -export interface TimelineUIProviderProps { - children: ReactNode; - initialPlayhead?: Frame; - initialZoom?: number; - initialSnappingEnabled?: boolean; - initialEditingMode?: 'normal' | 'ripple' | 'insert'; -} - -/** - * TimelineUI Provider - * - * Provides UI interaction state to all timeline components. - * Separate from engine state. - */ -export function TimelineUIProvider({ - children, - initialPlayhead = frame(0), - initialZoom = 1, - initialSnappingEnabled = true, - initialEditingMode = 'normal', -}: TimelineUIProviderProps) { - const [playhead, setPlayhead] = useState(initialPlayhead); - const [zoom, setZoom] = useState(initialZoom); - const [snappingEnabled, setSnappingEnabled] = useState(initialSnappingEnabled); - const [editingMode, setEditingMode] = useState<'normal' | 'ripple' | 'insert'>(initialEditingMode); - const [selectedClipIds, setSelectedClipIds] = useState>(new Set()); - - const toggleClipSelection = (clipId: string) => { - setSelectedClipIds((prev) => { - const next = new Set(prev); - if (next.has(clipId)) { - next.delete(clipId); - } else { - next.add(clipId); - } - return next; - }); - }; - - const clearSelection = () => { - setSelectedClipIds(new Set()); - }; - - const value: TimelineUIContextValue = { - state: { - playhead, - zoom, - snappingEnabled, - editingMode, - selectedClipIds, - }, - actions: { - setPlayhead, - setZoom, - setSnappingEnabled, - setEditingMode, - setSelectedClipIds, - toggleClipSelection, - clearSelection, - }, - }; - - return ( - - {children} - - ); -} - -/** - * Hook to access TimelineUI context - * - * @throws Error if used outside TimelineUIProvider - */ -export function useTimelineUI(): TimelineUIContextValue { - const context = useContext(TimelineUIContext); - - if (!context) { - throw new Error('useTimelineUI must be used within TimelineUIProvider'); - } - - return context; -} diff --git a/packages/ui/src/context/index.ts b/packages/ui/src/context/index.ts deleted file mode 100644 index 0011494..0000000 --- a/packages/ui/src/context/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { TimelineUIProvider, useTimelineUI } from './TimelineUIContext'; -export type { TimelineUIState, TimelineUIActions, TimelineUIContextValue } from './TimelineUIContext'; diff --git a/packages/ui/src/context/timeline-context.tsx b/packages/ui/src/context/timeline-context.tsx new file mode 100644 index 0000000..85f2b90 --- /dev/null +++ b/packages/ui/src/context/timeline-context.tsx @@ -0,0 +1,109 @@ +/** + * Timeline Context — coordination hub for all timeline components. + * + * Provides engine, zoom/scroll state, and layout constants to all + * child components via React context. No prop drilling needed. + */ +import React, { createContext, useContext, useState, useCallback, useRef } from 'react'; +import type { TimelineEngine } from '@webpacked-timeline/react'; + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface TimelineContextValue { + engine: TimelineEngine; + ppf: number; + ppfRef: React.MutableRefObject; + setPpf: (v: number) => void; + scrollLeft: number; + scrollRef: React.MutableRefObject; + setScrollLeft: (v: number) => void; + vpWidth: number; + setVpWidth: (v: number) => void; + labelWidth: number; + rulerHeight: number; + toolbarHeight: number; +} + +export interface TimelineProviderProps { + engine: TimelineEngine; + children: React.ReactNode; + initialPpf?: number; + onPpfChange?: (ppf: number) => void; + labelWidth?: number; + rulerHeight?: number; + toolbarHeight?: number; +} + +// ── Context ──────────────────────────────────────────────────────────────── + +const TimelineCtx = createContext(null); + +// ── Provider ─────────────────────────────────────────────────────────────── + +export function TimelineProvider({ + engine, + children, + initialPpf = 4, + onPpfChange, + labelWidth = 200, + rulerHeight = 32, + toolbarHeight = 40, +}: TimelineProviderProps) { + // ── PPF (pixels per frame) ── + const [ppf, setPpfState] = useState(initialPpf); + const ppfRef = useRef(initialPpf); + + const setPpf = useCallback( + (v: number) => { + const clamped = Math.max(0.5, Math.min(100, v)); + ppfRef.current = clamped; + setPpfState(clamped); + onPpfChange?.(clamped); + }, + [onPpfChange], + ); + + // ── Scroll ── + const [scrollLeft, setScrollState] = useState(0); + const scrollRef = useRef(0); + + const setScrollLeft = useCallback((v: number) => { + const clamped = Math.max(0, v); + scrollRef.current = clamped; + setScrollState(clamped); + }, []); + + // ── Viewport width ── + const [vpWidth, setVpWidth] = useState(1200); + + const value: TimelineContextValue = { + engine, + ppf, + ppfRef, + setPpf, + scrollLeft, + scrollRef, + setScrollLeft, + vpWidth, + setVpWidth, + labelWidth, + rulerHeight, + toolbarHeight, + }; + + return {children}; +} + +// ── Hooks ────────────────────────────────────────────────────────────────── + +export function useTimelineContext(): TimelineContextValue { + const ctx = useContext(TimelineCtx); + if (!ctx) { + throw new Error('useTimelineContext must be used within a '); + } + return ctx; +} + +export function useEngine(): TimelineEngine { + return useTimelineContext().engine; +} diff --git a/packages/ui/src/davinci.css b/packages/ui/src/davinci.css new file mode 100644 index 0000000..9ace613 --- /dev/null +++ b/packages/ui/src/davinci.css @@ -0,0 +1,45 @@ +/* ═══════════════════════════════════════════════════════════ + @webpacked-timeline/ui — DaVinci Resolve Theme + + Imports base tokens and applies DaVinci-specific overrides. + Import this file to get the full DaVinci look: + + import '@webpacked-timeline/ui/styles/davinci'; + ═══════════════════════════════════════════════════════════ */ + +@import './tokens.css'; + +:root { + /* DaVinci-specific overrides */ + --tl-track-height: 80px; + --tl-track-height-audio: 80px; + --tl-timecode-color: hsl(0 0% 88%); + --tl-label-width: 200px; + --tl-clip-video-accent: hsl(195 85% 72%); + --tl-clip-video-text: hsl(195 85% 15%); + --tl-clip-audio-text: hsl(142 70% 12%); +} + +/* ── Scrollbar styling ── */ +.tl-scroll-area::-webkit-scrollbar { + height: 10px; + width: 10px; +} +.tl-scroll-area::-webkit-scrollbar-track { + background: var(--tl-scrollbar-track); +} +.tl-scroll-area::-webkit-scrollbar-thumb { + background: var(--tl-scrollbar-thumb); + border-radius: 5px; +} +.tl-scroll-area::-webkit-scrollbar-thumb:hover { + background: var(--tl-scrollbar-thumb-hover); +} +.tl-scroll-area::-webkit-scrollbar-corner { + background: var(--tl-scrollbar-track); +} + +/* ── Button hover ── */ +.tl-btn:hover { + background: var(--tl-btn-bg-hover) !important; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index dceae50..ed53ef6 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,2 +1,51 @@ -export * from './timeline'; -export * from './context'; +/** + * @webpacked-timeline/ui — Public API + * + * DaVinci Resolve–style timeline editor components. + * + * Quick start: + * import { DaVinciEditor } from '@webpacked-timeline/ui'; + * import '@webpacked-timeline/ui/styles/davinci'; + * + * + */ + +// ── DaVinci Preset (the main thing most users want) ──────────────────────── +export { + DaVinciEditor, + DaVinciToolbar, + DaVinciRuler, + DaVinciTrack, + DaVinciClip, + DaVinciPlayhead, +} from './presets/davinci'; +export type { + DaVinciEditorProps, + DaVinciRulerProps, + DaVinciTrackProps, + DaVinciClipProps, + DaVinciPlayheadProps, +} from './presets/davinci'; + +// ── Context (for custom layouts) ─────────────────────────────────────────── +export { + TimelineProvider, + useTimelineContext, + useEngine, +} from './context/timeline-context'; +export type { + TimelineContextValue, + TimelineProviderProps, +} from './context/timeline-context'; + +// ── Shared utilities ─────────────────────────────────────────────────────── +export { + frameToPx, + pxToFrame, + frameToTimecode, + rulerTickInterval, +} from './shared/time'; + +export { useTimelineRefs } from './shared/use-refs'; +export { clamp } from './shared/geometry'; +export { cn } from './shared/cn'; diff --git a/packages/ui/src/presets/davinci/davinci-clip.tsx b/packages/ui/src/presets/davinci/davinci-clip.tsx new file mode 100644 index 0000000..de6eca6 --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-clip.tsx @@ -0,0 +1,287 @@ +/** + * DaVinciClip — renders a single clip block. + * + * Purely presentational: receives all data as props. + * Positioned absolutely within its parent track clip row. + * Uses CSS variables for theming. + */ +import React, { useState, useRef, useMemo, useEffect } from 'react'; +import type { Clip, ProvisionalState } from '@webpacked-timeline/core'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function getDisplayClip(clip: Clip, provisional: ProvisionalState | null): Clip { + if (!provisional?.clips) return clip; + return provisional.clips.find((c) => c.id === clip.id) ?? clip; +} + +function seededRandom(seed: string) { + let h = 0; + for (let i = 0; i < seed.length; i++) { + h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0; + } + return () => { + h ^= h << 13; + h ^= h >> 17; + h ^= h << 5; + return (h >>> 0) / 0xffffffff; + }; +} + +// ── ClipWaveform ─────────────────────────────────────────────────────────── + +function ClipWaveform({ clipId, width, height }: { clipId: string; width: number; height: number }) { + const canvasRef = useRef(null); + const samples = useMemo(() => { + const rand = seededRandom(clipId); + const w = Math.max(1, Math.round(width)); + return Array.from({ length: w }, () => rand() * 2 - 1); + }, [clipId, width]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const w = Math.round(width); + const h = height; + canvas.width = w; + canvas.height = h; + ctx.clearRect(0, 0, w, h); + const mid = h / 2; + + // Subtle center line + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(0, mid); + ctx.lineTo(w, mid); + ctx.stroke(); + + // Continuous waveform line + ctx.strokeStyle = 'rgba(255,255,255,0.7)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x < w; x++) { + const sampleIndex = Math.floor(x / w * samples.length); + const sample = samples[sampleIndex]!; + const y = mid + sample * mid * 0.6; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + }, [samples, width, height]); + + return ( + + ); +} + +// ── Props ────────────────────────────────────────────────────────────────── + +export interface DaVinciClipProps { + clip: Clip; + provisional: ProvisionalState | null; + trackId: string; + isAudio: boolean; + ppf: number; + height: number; + isSelected: boolean; + toolId: string; + fps: number; + startFrame: number; + endFrame: number; +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function DaVinciClip({ + clip, + provisional, + trackId, + isAudio, + ppf, + height, + isSelected, + toolId, + fps, + startFrame, + endFrame, +}: DaVinciClipProps) { + const [isHovered, setIsHovered] = useState(false); + + const dc = getDisplayClip(clip, provisional); + const start = dc.timelineStart as number; + const dur = (dc.timelineEnd as number) - start; + const left = start * ppf; + const width = dur * ppf; + + // Virtual windowing: skip clips entirely outside visible range + if (start + dur <= startFrame || start >= endFrame) return null; + if (width < 1) return null; + + const isProvisional = !!provisional?.clips?.find((c) => c.id === clip.id); + const showClipDetail = ppf >= 3; + const showClipFull = ppf >= 8; + const showDuration = ppf >= 5; + const isThin = width < 4; + const durationSec = (dur / fps).toFixed(1); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ + position: 'absolute', + left, + top: 0, + width: Math.max(width, 2), + height, + background: isThin + ? isAudio ? 'var(--tl-clip-audio-accent)' : 'var(--tl-clip-video-accent)' + : isProvisional + ? 'var(--tl-clip-provisional)' + : isAudio + ? 'linear-gradient(to bottom, var(--tl-clip-audio-top), var(--tl-clip-audio-bg))' + : 'linear-gradient(to bottom, var(--tl-clip-video-top), var(--tl-clip-video-bg))', + border: isThin ? 'none' : '1px solid', + borderColor: isSelected ? 'var(--tl-clip-border-sel)' : 'var(--tl-clip-border)', + borderRadius: isThin ? 0 : 'var(--tl-clip-radius)', + overflow: 'hidden', + cursor: + toolId === 'razor' + ? 'crosshair' + : toolId === 'hand' + ? 'grab' + : 'pointer', + userSelect: 'none', + filter: isSelected ? 'brightness(1.3)' : isHovered ? 'brightness(1.1)' : undefined, + }} + > + {!isThin && ( + <> + {/* Top accent strip — video clips only */} + {!isAudio && ( +
+ )} + + {/* Clip label */} + {showClipDetail && width > 30 && ( + + 🔗 {clip.name ?? clip.id} + + )} + + {/* Type icon — bottom-left */} + {showClipFull && width > 80 && ( + + {isAudio ? '♪' : '▶'} + + )} + + {/* Duration label — bottom-right */} + {showDuration && width > 60 && ( + + {durationSec}s + + )} + + {/* Audio waveform */} + {isAudio && width > 40 && ( + + )} + + {/* Trim handles on hover */} + {showClipFull && isHovered && ( + <> +
+
+ + )} + + )} +
+ ); +} diff --git a/packages/ui/src/presets/davinci/davinci-editor.tsx b/packages/ui/src/presets/davinci/davinci-editor.tsx new file mode 100644 index 0000000..f28228b --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-editor.tsx @@ -0,0 +1,917 @@ +/** + * DaVinciEditor — flagship timeline editor component. + * + * Composes all DaVinci preset components into a complete + * DaVinci Resolve–style timeline editor. Owns all layout + * coordination, pointer/keyboard handling, and scroll sync. + * + * Usage: + * import { DaVinciEditor } from '@webpacked-timeline/ui'; + * import '@webpacked-timeline/ui/styles/davinci'; + * + * + */ +import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react'; +import { + useTrackIdsWithEngine, + useTimelineWithEngine, + usePlayheadFrame, + useIsPlaying, + useActiveToolId, + useHistory, + useProvisionalWithEngine, + useSelectedClipIds, + useCursor, + useVirtualWindow, + useMarkers, + useTrackWithEngine, + useClips, +} from '@webpacked-timeline/react'; +import type { TimelineEngine } from '@webpacked-timeline/react'; +import type { + TimelinePointerEvent, + TimelineKeyEvent, + Modifiers, + ClipId, + TrackId, + ProvisionalState, +} from '@webpacked-timeline/core'; +import { toFrame, createTrack, toTrackId, createClip, toAssetId, createAsset, frameRate } from '@webpacked-timeline/core'; +import { TimelineProvider, useTimelineContext } from '../../context/timeline-context'; +import { DaVinciToolbar } from './davinci-toolbar'; +import { DaVinciRuler } from './davinci-ruler'; +import { DaVinciTrack } from './davinci-track'; +import { DaVinciClip } from './davinci-clip'; +import { DaVinciPlayhead } from './davinci-playhead'; + +// ── Constants ────────────────────────────────────────────────────────────── + +const DEFAULT_TRACK_HEIGHT_VIDEO = 80; +const DEFAULT_TRACK_HEIGHT_AUDIO = 80; +const MIN_TRACK_HEIGHT = 32; +const MAX_TRACK_HEIGHT = 125; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +let _txSeq = 0; +const txId = () => `ui-tx-${++_txSeq}`; + +function extractModifiers(e: React.PointerEvent | React.KeyboardEvent): Modifiers { + return { shift: e.shiftKey, alt: e.altKey, ctrl: e.ctrlKey, meta: e.metaKey }; +} + +function getShortTrackId( + trackId: string, + type: string, + allTrackIds: readonly string[], + trackTypes: Map, +): string { + const prefix = type === 'video' ? 'V' : type === 'audio' ? 'A' : type === 'subtitle' ? 'S' : 'T'; + let idx = 1; + for (const tid of allTrackIds) { + if (tid === trackId) break; + if (trackTypes.get(tid) === type) idx++; + } + return `${prefix}${idx}`; +} + +// ── ClipRow (internal) ───────────────────────────────────────────────────── + +function ClipRow({ + trackId, + ppf, + provisional, + selection, + toolId, + height, + fps, + startFrame, + endFrame, +}: { + trackId: string; + ppf: number; + provisional: ProvisionalState | null; + selection: ReadonlySet; + toolId: string; + height: number; + fps: number; + startFrame: number; + endFrame: number; +}) { + const { engine } = useTimelineContext(); + const track = useTrackWithEngine(engine, trackId); + const clips = useClips(engine, trackId); + const [trackHovered, setTrackHovered] = useState(false); + + if (!track) return null; + const isAudio = track.type === 'audio'; + + return ( +
setTrackHovered(true)} + onMouseLeave={() => setTrackHovered(false)} + > + {/* Empty track indicator */} + {clips.length === 0 && ( + <> +
+ {trackHovered && ( + + Drop clips here or click + + + )} + + )} + + {clips.map((clip) => ( + + ))} +
+ ); +} + +// ── Public Props ─────────────────────────────────────────────────────────── + +export interface DaVinciEditorProps { + engine: TimelineEngine; + initialPpf?: number; + /** Called when ppf changes (sync to engine's getPixelsPerFrame) */ + onPpfChange?: (ppf: number) => void; + /** Editor calls this on mount with its setPpf function (for engine → editor zoom) */ + registerZoomHandler?: (handler: (ppf: number) => void) => void; + className?: string; + style?: React.CSSProperties; +} + +// ── Public Component ─────────────────────────────────────────────────────── + +export function DaVinciEditor({ + engine, + initialPpf = 4, + onPpfChange, + registerZoomHandler, + className, + style, +}: DaVinciEditorProps) { + return ( + + + + ); +} + +// ── Inner Editor (reads from context) ────────────────────────────────────── + +function EditorInner({ + registerZoomHandler, + className, + style, +}: { + registerZoomHandler?: (handler: (ppf: number) => void) => void; + className?: string; + style?: React.CSSProperties; +}) { + const { + engine, + ppf, + ppfRef, + setPpf, + scrollLeft, + scrollRef, + setScrollLeft, + vpWidth, + setVpWidth, + labelWidth, + rulerHeight, + toolbarHeight, + } = useTimelineContext(); + + // ── Force re-render ── + const [, forceUpdate] = useState(0); + const triggerUpdate = useCallback(() => forceUpdate((n) => n + 1), []); + + // ── Refs ── + const trackAreaRef = useRef(null); + const scrollContainerRef = useRef(null); + const labelColumnRef = useRef(null); + const rulerContentRef = useRef(null); + const handDragRef = useRef<{ startX: number; startScroll: number } | null>(null); + const resizeDragRef = useRef<{ trackId: string; startY: number; startHeight: number } | null>(null); + + // ── Track height state ── + const [trackHeights, setTrackHeights] = useState>({}); + + // ── Engine data ── + const trackIds = useTrackIdsWithEngine(engine); + const timeline = useTimelineWithEngine(engine); + const frame = usePlayheadFrame(engine); + const isPlaying = useIsPlaying(engine); + const toolId = useActiveToolId(engine); + const provisional = useProvisionalWithEngine(engine); + const selection = useSelectedClipIds(engine); + const cursor = useCursor(engine); + const virtualWindow = useVirtualWindow(engine, vpWidth, scrollLeft, ppf); + + const fps = timeline.fps as number; + const durationFrames = timeline.duration as number; + + // ── Snap indicator frames ── + const snapFrames = useMemo(() => { + if (!provisional?.clips?.length) return []; + const state = engine.getState(); + const committedEdges = new Set(); + const committedClipMap = new Map(); + for (const track of state.timeline.tracks) { + for (const clip of track.clips) { + const s = clip.timelineStart as number; + const e = clip.timelineEnd as number; + committedEdges.add(s); + committedEdges.add(e); + committedClipMap.set(clip.id as string, { start: s, end: e }); + } + } + committedEdges.add(frame as number); + const snapped = new Set(); + for (const pc of provisional.clips) { + const committed = committedClipMap.get(pc.id as string); + if (!committed) continue; + const ps = pc.timelineStart as number; + const pe = pc.timelineEnd as number; + if (ps !== committed.start && committedEdges.has(ps)) snapped.add(ps); + if (pe !== committed.end && committedEdges.has(pe)) snapped.add(pe); + } + return Array.from(snapped); + }, [provisional, frame, engine]); + + // ── Build track type map ── + const trackTypesMap = useMemo(() => { + const map = new Map(); + const state = engine.getState(); + for (const t of state.timeline.tracks) { + map.set(t.id as string, t.type); + } + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trackIds]); + + // ── Get height for a track ── + const getTrackHeight = useCallback( + (trackId: string) => { + if (trackHeights[trackId] !== undefined) return trackHeights[trackId]; + const type = trackTypesMap.get(trackId); + return type === 'video' ? DEFAULT_TRACK_HEIGHT_VIDEO : DEFAULT_TRACK_HEIGHT_AUDIO; + }, + [trackHeights, trackTypesMap], + ); + + const totalTrackHeight = useMemo( + () => trackIds.reduce((sum, tid) => sum + getTrackHeight(tid), 0), + [trackIds, getTrackHeight], + ); + + // ── Track add / delete ── + const addTrack = useCallback( + (type: 'video' | 'audio') => { + const id = `track-${Date.now()}`; + const name = + type === 'video' + ? `Video ${trackIds.filter((t) => trackTypesMap.get(t) === 'video').length + 1}` + : `Audio ${trackIds.filter((t) => trackTypesMap.get(t) === 'audio').length + 1}`; + engine.dispatch({ + id: txId(), + label: `Add ${type} track`, + timestamp: Date.now(), + operations: [{ type: 'ADD_TRACK', track: createTrack({ id, name, type }) }] as any, + }); + triggerUpdate(); + }, + [engine, trackIds, trackTypesMap, triggerUpdate], + ); + + const deleteTrack = useCallback( + (trackId: string) => { + engine.dispatch({ + id: txId(), + label: 'Delete track', + timestamp: Date.now(), + operations: [{ type: 'DELETE_TRACK', trackId }] as any, + }); + triggerUpdate(); + }, + [engine, triggerUpdate], + ); + + // ── Add clip to track ── + const addClip = useCallback( + (trackId: string) => { + const state = engine.getState(); + const track = state.timeline.tracks.find((t) => (t.id as string) === trackId); + const lastClip = track?.clips.reduce( + (latest: any, c: any) => + (c.timelineEnd as number) > ((latest?.timelineEnd as number) ?? 0) ? c : latest, + null as any, + ); + const startFrame = lastClip ? (lastClip.timelineEnd as number) + 30 : 0; + const dur = 90; + const clipId = `clip-${Date.now()}`; + const assetId = `asset-${clipId}`; + const trackType = track?.type ?? 'video'; + const mediaType = trackType === 'audio' ? 'audio' : 'video'; + + const asset = createAsset({ + id: assetId, + name: `New ${mediaType} clip`, + mediaType: mediaType as any, + filePath: `generator://${assetId}`, + intrinsicDuration: toFrame(dur), + nativeFps: frameRate(fps), + sourceTimecodeOffset: toFrame(0), + }); + + const clip = createClip({ + id: clipId, + assetId, + trackId, + timelineStart: toFrame(startFrame), + timelineEnd: toFrame(startFrame + dur), + mediaIn: toFrame(0), + mediaOut: toFrame(dur), + name: `New ${mediaType} clip`, + }); + + engine.dispatch({ + id: txId(), + label: 'Add Clip', + timestamp: Date.now(), + operations: [{ type: 'INSERT_CLIP', clip, trackId }] as any, + }); + triggerUpdate(); + }, + [engine, fps, triggerUpdate], + ); + + // ── Clip counts per track ── + const clipCounts = useMemo(() => { + const map = new Map(); + const state = engine.getState(); + for (const t of state.timeline.tracks) { + map.set(t.id as string, t.clips.length); + } + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trackIds, provisional]); + + // ── Observe viewport width ── + useEffect(() => { + const el = scrollContainerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + setVpWidth(entry.contentRect.width); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, [setVpWidth]); + + // ── Wire zoom callback on mount ── + useEffect(() => { + registerZoomHandler?.(setPpf); + }, [registerZoomHandler, setPpf]); + + // ── Playhead auto-scroll during playback ── + useEffect(() => { + if (!isPlaying) return; + const playheadX = (frame as number) * ppfRef.current; + const viewStart = scrollRef.current; + const viewEnd = viewStart + vpWidth; + if (playheadX > viewEnd - 80 || playheadX < viewStart + 20) { + const newScroll = Math.max(0, playheadX - vpWidth * 0.2); + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollLeft = newScroll; + } + setScrollLeft(newScroll); + } + }, [frame, isPlaying, vpWidth, setScrollLeft, ppfRef, scrollRef]); + + // ── Track resize: document-level listeners ── + useEffect(() => { + const onMove = (e: PointerEvent) => { + if (!resizeDragRef.current) return; + const dy = e.clientY - resizeDragRef.current.startY; + const newH = Math.max(MIN_TRACK_HEIGHT, Math.min(MAX_TRACK_HEIGHT, resizeDragRef.current.startHeight + dy)); + setTrackHeights((prev) => ({ ...prev, [resizeDragRef.current!.trackId]: newH })); + }; + const onUp = () => { + resizeDragRef.current = null; + }; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + return () => { + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + }; + }, []); + + // ── Pointer event converter ── + const convertEvent = useCallback( + (e: React.PointerEvent): TimelinePointerEvent => { + const currentPpf = ppfRef.current; + const rect = trackAreaRef.current!.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const f = Math.max(0, Math.round(x / currentPpf)); + + let clipId: string | null = null; + let trackId: string | null = null; + let clipEl: HTMLElement | null = null; + + let el = e.target as HTMLElement | null; + while (el && el !== trackAreaRef.current) { + if (!clipId && el.dataset.clipId) { + clipId = el.dataset.clipId; + clipEl = el; + } + if (!trackId && el.dataset.trackId) { + trackId = el.dataset.trackId; + } + if (clipId && trackId) break; + el = el.parentElement; + } + + let edge: 'left' | 'right' | 'none' = 'none'; + if (clipEl) { + const cr = clipEl.getBoundingClientRect(); + const lx = e.clientX - cr.left; + const thresh = Math.min(8, cr.width * 0.2); + edge = lx <= thresh ? 'left' : lx >= cr.width - thresh ? 'right' : 'none'; + } + + return { + x, + y, + frame: toFrame(f), + buttons: e.buttons, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + clipId: clipId as ClipId | null, + trackId: trackId as TrackId | null, + edge, + }; + }, + [ppfRef], + ); + + // ── Pointer handlers ── + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + const currentToolId = engine.getActiveToolId(); + if (currentToolId === 'hand') { + handDragRef.current = { startX: e.clientX, startScroll: scrollRef.current }; + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + return; + } + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + engine.handlePointerDown(convertEvent(e), extractModifiers(e)); + triggerUpdate(); + }, + [engine, convertEvent, triggerUpdate, scrollRef], + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + const currentToolId = engine.getActiveToolId(); + if (currentToolId === 'hand' && handDragRef.current && e.buttons & 1) { + const dx = e.clientX - handDragRef.current.startX; + const newScroll = Math.max(0, handDragRef.current.startScroll - dx); + setScrollLeft(newScroll); + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollLeft = newScroll; + } + return; + } + if (!(e.buttons & 1)) return; + engine.handlePointerMove(convertEvent(e), extractModifiers(e)); + triggerUpdate(); + }, + [engine, convertEvent, triggerUpdate, setScrollLeft], + ); + + const onPointerUp = useCallback( + (e: React.PointerEvent) => { + const currentToolId = engine.getActiveToolId(); + if (currentToolId === 'hand') { + handDragRef.current = null; + return; + } + engine.handlePointerUp(convertEvent(e), extractModifiers(e)); + triggerUpdate(); + }, + [engine, convertEvent, triggerUpdate], + ); + + const onPointerLeave = useCallback( + (e: React.PointerEvent) => { + const currentToolId = engine.getActiveToolId(); + if (currentToolId === 'hand') { + handDragRef.current = null; + return; + } + const evt = convertEvent(e); + engine.handlePointerUp(evt, extractModifiers(e)); + engine.handlePointerLeave(evt); + }, + [engine, convertEvent], + ); + + // ── Keyboard handler ── + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const toolKeys: Record = { + v: 'selection', + c: 'razor', + t: 'ripple-trim', + r: 'roll-trim', + s: 'slip', + y: 'slide', + h: 'hand', + }; + if (!e.metaKey && !e.ctrlKey && !e.altKey && toolKeys[e.key.toLowerCase()]) { + e.preventDefault(); + engine.activateTool(toolKeys[e.key.toLowerCase()]); + return; + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'z') { + e.preventDefault(); + e.shiftKey ? engine.redo() : engine.undo(); + return; + } + + if (e.key === 'Delete' || e.key === 'Backspace') { + const sel = engine.getSnapshot().selectedClipIds; + if (sel.size > 0) { + e.preventDefault(); + const ops: Array<{ type: 'DELETE_CLIP'; clipId: ClipId; trackId: TrackId }> = []; + for (const cid of sel) { + for (const trk of engine.getState().timeline.tracks) { + const c = trk.clips.find((cl) => cl.id === cid); + if (c) { + ops.push({ type: 'DELETE_CLIP', clipId: c.id, trackId: trk.id }); + break; + } + } + } + if (ops.length > 0) { + engine.dispatch({ + id: `delete-${Date.now()}`, + label: 'Delete clips', + timestamp: Date.now(), + operations: ops as any, + }); + engine.clearSelection(); + } + } + return; + } + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + const step = e.shiftKey ? 10 : 1; + engine.seekTo(toFrame(Math.max(0, (frame as number) - step))); + return; + } + if (e.key === 'ArrowRight') { + e.preventDefault(); + const step = e.shiftKey ? 10 : 1; + engine.seekTo(toFrame(Math.min(durationFrames - 1, (frame as number) + step))); + return; + } + + if (e.key === ' ') { + e.preventDefault(); + isPlaying ? engine.playbackEngine?.pause() : engine.playbackEngine?.play(); + return; + } + + if (e.key === 'Home') { + e.preventDefault(); + engine.seekTo(toFrame(0)); + return; + } + if (e.key === 'End') { + e.preventDefault(); + engine.seekTo(toFrame(durationFrames - 1)); + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + engine.clearSelection(); + return; + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'a') { + e.preventDefault(); + const allIds = new Set(); + for (const trk of engine.getState().timeline.tracks) { + for (const c of trk.clips) allIds.add(c.id as string); + } + engine.setSelectedClipIds(allIds); + return; + } + + const keyEvt: TimelineKeyEvent = { + code: e.code, + key: e.key, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + repeat: e.repeat, + }; + const handled = engine.handleKeyDown(keyEvt, extractModifiers(e)); + if (handled) e.preventDefault(); + }, + [engine, frame, durationFrames, isPlaying], + ); + + // ── Scroll handler ── + const onScroll = useCallback( + (e: React.UIEvent) => { + const sl = e.currentTarget.scrollLeft; + const st = e.currentTarget.scrollTop; + setScrollLeft(sl); + if (labelColumnRef.current) { + labelColumnRef.current.scrollTop = st; + } + if (rulerContentRef.current) { + rulerContentRef.current.scrollLeft = sl; + } + }, + [setScrollLeft], + ); + + // ── Computed ── + const timelineWidth = durationFrames * ppf; + const firstAudioIdx = trackIds.findIndex((tid) => trackTypesMap.get(tid) === 'audio'); + + // ── Render ─────────────────────────────────────────────────────────────── + + return ( +
+ {/* ═══ TOOLBAR ═══ */} + + + {/* ═══ RULER ROW ═══ */} + + + {/* ═══ TRACK AREA ═══ */} +
+ {/* ── Left: track labels ── */} +
+ {/* Add track buttons header */} +
+ + +
+ {trackIds.map((tid, i) => { + const h = getTrackHeight(tid); + const type = trackTypesMap.get(tid) ?? 'video'; + const shortId = getShortTrackId(tid, type, trackIds, trackTypesMap); + const isSep = firstAudioIdx > 0 && i === firstAudioIdx; + return ( +
+ {isSep &&
} + + {/* Resize handle */} +
{ + e.stopPropagation(); + resizeDragRef.current = { trackId: tid, startY: e.clientY, startHeight: h }; + }} + onMouseEnter={(ev) => { + (ev.currentTarget as HTMLElement).style.background = 'var(--tl-resize-handle)'; + }} + onMouseLeave={(ev) => { + (ev.currentTarget as HTMLElement).style.background = 'transparent'; + }} + /> +
+ ); + })} +
+ + {/* ── Right: clip scroll area ── */} +
+
+ {/* Track clip rows */} + {trackIds.map((tid, i) => { + const isSep = firstAudioIdx > 0 && i === firstAudioIdx; + return ( + + {isSep &&
} + + + ); + })} + + {/* Snap indicator lines */} + {snapFrames.map((sf) => ( +
+ ))} + + {/* Playhead */} + +
+
+
+
+ ); +} diff --git a/packages/ui/src/presets/davinci/davinci-playhead.tsx b/packages/ui/src/presets/davinci/davinci-playhead.tsx new file mode 100644 index 0000000..418014b --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-playhead.tsx @@ -0,0 +1,37 @@ +/** + * DaVinciPlayhead — the red playhead line + head triangle. + * + * Reads frame from engine via hook, ppf from context. + * Rendered as an absolute-positioned overlay. + */ +import React from 'react'; +import { usePlayheadFrame } from '@webpacked-timeline/react'; +import { useTimelineContext } from '../../context/timeline-context'; + +export interface DaVinciPlayheadProps { + /** Total height of the track area (line extends full height) */ + totalHeight: number; + /** Extra top offset (e.g. for add-track header alignment) */ + topOffset?: number; +} + +export function DaVinciPlayhead({ totalHeight, topOffset = 0 }: DaVinciPlayheadProps) { + const { engine, ppf } = useTimelineContext(); + const frame = usePlayheadFrame(engine); + + return ( +
+ ); +} diff --git a/packages/ui/src/presets/davinci/davinci-ruler.tsx b/packages/ui/src/presets/davinci/davinci-ruler.tsx new file mode 100644 index 0000000..8045dd4 --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-ruler.tsx @@ -0,0 +1,232 @@ +/** + * DaVinciRuler — timecode display + tick marks. + * + * Left section: current timecode (fixed label-width area). + * Right section: scrollable tick marks with labels, markers, playhead triangle. + */ +import React, { useMemo, useCallback } from 'react'; +import { + usePlayheadFrame, + useTimelineWithEngine, + useMarkers, +} from '@webpacked-timeline/react'; +import { toFrame, toMarkerId } from '@webpacked-timeline/core'; +import { useTimelineContext } from '../../context/timeline-context'; +import { frameToTimecode, rulerTickInterval } from '../../shared/time'; + +// ── Transaction ID helper ────────────────────────────────────────────────── + +let _rulerTxSeq = 0; +const txId = () => `ruler-tx-${++_rulerTxSeq}`; + +// ── Props ────────────────────────────────────────────────────────────────── + +export interface DaVinciRulerProps { + /** Ref attached to the tick content div — needed for scroll sync from parent */ + contentRef?: React.RefObject; +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function DaVinciRuler({ contentRef }: DaVinciRulerProps) { + const { engine, ppf, scrollLeft, vpWidth, labelWidth, rulerHeight } = useTimelineContext(); + const frame = usePlayheadFrame(engine); + const timeline = useTimelineWithEngine(engine); + const markers = useMarkers(engine); + + const fps = timeline.fps as number; + const durationFrames = timeline.duration as number; + const timelineWidth = durationFrames * ppf; + + // ── Timecode ── + const timecode = useMemo( + () => frameToTimecode(frame as number, fps), + [frame, fps], + ); + + // ── Ruler ticks (visible only) ── + const rulerTicks = useMemo(() => { + const startF = Math.floor(scrollLeft / ppf); + const endF = startF + Math.ceil(vpWidth / ppf) + 1; + const { major, minor } = rulerTickInterval(ppf, fps); + + const ticks: Array<{ frame: number; isMajor: boolean; x: number }> = []; + const first = Math.floor(startF / minor) * minor; + for (let f = first; f <= endF; f += minor) { + ticks.push({ frame: f, isMajor: f % major === 0, x: f * ppf }); + } + return ticks; + }, [scrollLeft, ppf, vpWidth, fps]); + + // ── Click handler (seek or add marker) ── + const [, forceUpdate] = React.useState(0); + const triggerUpdate = useCallback(() => forceUpdate((n) => n + 1), []); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left + (contentRef?.current?.scrollLeft ?? 0); + const f = Math.max(0, Math.round(x / ppf)); + + if (e.altKey) { + engine.dispatch({ + id: txId(), + label: 'Add Marker', + timestamp: Date.now(), + operations: [ + { + type: 'ADD_MARKER', + marker: { + type: 'point', + id: toMarkerId(`marker-${Date.now()}`), + frame: toFrame(f), + label: `M ${f}`, + color: 'hsl(45 90% 60%)', + scope: 'global', + linkedClipId: null, + }, + }, + ] as any, + }); + triggerUpdate(); + } else { + engine.seekTo(toFrame(f)); + triggerUpdate(); + } + }, + [engine, ppf, contentRef, triggerUpdate], + ); + + return ( +
+ {/* ── Timecode (left, label-width, aligned with track labels) ── */} +
+ + {timecode} + +
+ + {/* ── Ruler ticks (right, flex:1, overflow hidden) ── */} +
} + style={{ + flex: 1, + position: 'relative', + overflowX: 'hidden', + overflowY: 'hidden', + cursor: 'pointer', + borderBottom: '1px solid var(--tl-track-border)', + }} + onClick={handleClick} + > +
+ {rulerTicks.map((tick) => ( +
+
+ {tick.isMajor && tick.x > 4 && ( + + {frameToTimecode(tick.frame, fps)} + + )} +
+ ))} + + {/* Marker triangles */} + {markers.map((m) => { + if (m.type !== 'point') return null; + const mx = (m.frame as number) * ppf; + return ( +
+ ); + })} + + {/* Playhead triangle */} +
+
+
+
+ ); +} diff --git a/packages/ui/src/presets/davinci/davinci-toolbar.tsx b/packages/ui/src/presets/davinci/davinci-toolbar.tsx new file mode 100644 index 0000000..d9b3577 --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-toolbar.tsx @@ -0,0 +1,179 @@ +/** + * DaVinciToolbar — tool buttons, zoom controls, transport. + * + * Three groups: [tools] | [zoom] | [undo/redo + play] + * Reads all state from context + @webpacked-timeline/react hooks. + */ +import React from 'react'; +import { + useActiveToolId, + useIsPlaying, + useHistory, + useSelectedClipIds, +} from '@webpacked-timeline/react'; +import { useTimelineContext } from '../../context/timeline-context'; +import { + TOOL_ICONS, + IconZoomOut, + IconZoomIn, + IconUndo, + IconRedo, + IconPlayerPlay, + IconPlayerPause, +} from './icons'; + +// ── Constants ────────────────────────────────────────────────────────────── + +const TOOLS = [ + { id: 'selection', label: 'Select', key: 'V' }, + { id: 'razor', label: 'Razor', key: 'C' }, + { id: 'ripple-trim', label: 'Trim', key: 'T' }, + { id: 'roll-trim', label: 'Roll', key: 'R' }, + { id: 'slip', label: 'Slip', key: 'S' }, + { id: 'slide', label: 'Slide', key: 'Y' }, + { id: 'hand', label: 'Hand', key: 'H' }, +] as const; + +const zoomBtnStyle: React.CSSProperties = { + padding: '3px 6px', + background: 'var(--tl-btn-bg)', + color: 'var(--tl-btn-text)', + border: '1px solid var(--tl-btn-border)', + borderRadius: 3, + cursor: 'pointer', + fontSize: 13, + fontFamily: 'monospace', + lineHeight: 1, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', +}; + +// ── Component ────────────────────────────────────────────────────────────── + +export function DaVinciToolbar() { + const { engine, ppf, setPpf, toolbarHeight } = useTimelineContext(); + const toolId = useActiveToolId(engine); + const isPlaying = useIsPlaying(engine); + const history = useHistory(engine); + const selection = useSelectedClipIds(engine); + + return ( +
+ {/* ── Left group: tool buttons ── */} +
+ {TOOLS.map((tool) => { + const Icon = TOOL_ICONS[tool.id]; + const isActive = toolId === tool.id; + return ( + + ); + })} +
+ + {/* ── Center group: zoom ── */} +
+ + setPpf(Math.exp(parseFloat(e.target.value)))} + style={{ width: 80, cursor: 'pointer' }} + /> + + + {ppf.toFixed(1)}px/f + +
+ + {/* ── Right group: undo/redo + play ── */} +
+ + + + {/* Separator */} +
+ + + + {selection.size > 0 && ( + + {selection.size} sel + + )} +
+
+ ); +} diff --git a/packages/ui/src/presets/davinci/davinci-track.tsx b/packages/ui/src/presets/davinci/davinci-track.tsx new file mode 100644 index 0000000..6e2e13b --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-track.tsx @@ -0,0 +1,246 @@ +/** + * DaVinciTrack — renders a track label row. + * + * Displays track name, type badge, lock/visibility icons, + * solo/mute buttons (audio), clip count, and action buttons. + * Uses CSS variables for theming. + */ +import React, { useState } from 'react'; +import { useTrackWithEngine } from '@webpacked-timeline/react'; +import { useTimelineContext } from '../../context/timeline-context'; + +// ── Props ────────────────────────────────────────────────────────────────── + +export interface DaVinciTrackProps { + trackId: string; + shortId: string; + height: number; + clipCount: number; + onDelete: (trackId: string) => void; + onAddClip: (trackId: string) => void; +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function DaVinciTrack({ + trackId, + shortId, + height, + clipCount, + onDelete, + onAddClip, +}: DaVinciTrackProps) { + const { engine } = useTimelineContext(); + const track = useTrackWithEngine(engine, trackId); + const [soloActive, setSoloActive] = useState(false); + const [muteActive, setMuteActive] = useState(false); + const [lockActive, setLockActive] = useState(false); + const [visActive, setVisActive] = useState(true); + + if (!track) return null; + + const isAudio = track.type === 'audio'; + const typeVar = + track.type === 'video' + ? 'var(--tl-type-video)' + : track.type === 'audio' + ? 'var(--tl-type-audio)' + : track.type === 'subtitle' + ? 'var(--tl-type-subtitle)' + : 'var(--tl-type-title)'; + + return ( +
+ {/* Type color bar */} +
+ + {/* Row 1: short id + name + icons */} +
+ + {shortId} + + + {track.name ?? shortId} + +
+
setLockActive(!lockActive)} + style={{ + width: 16, + height: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 9, + color: lockActive ? 'var(--tl-btn-text-active)' : 'var(--tl-label-text)', + background: lockActive ? 'hsl(220 13% 20%)' : 'transparent', + borderRadius: 2, + cursor: 'pointer', + }} + > + 🔒 +
+
setVisActive(!visActive)} + style={{ + width: 16, + height: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 9, + color: visActive ? 'var(--tl-label-text)' : 'hsl(0 0% 40%)', + background: !visActive ? 'hsl(220 13% 20%)' : 'transparent', + borderRadius: 2, + cursor: 'pointer', + }} + > + 👁 +
+
+
+ + {/* Delete track button — top-right */} +
{ + ev.stopPropagation(); + onDelete(trackId); + }} + style={{ + position: 'absolute', + top: 2, + right: 6, + width: 16, + height: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 11, + color: 'var(--tl-label-text-dim)', + cursor: 'pointer', + borderRadius: 2, + background: 'transparent', + }} + onMouseEnter={(ev) => { + (ev.currentTarget as HTMLElement).style.background = 'hsl(0 60% 40%)'; + (ev.currentTarget as HTMLElement).style.color = '#fff'; + }} + onMouseLeave={(ev) => { + (ev.currentTarget as HTMLElement).style.background = 'transparent'; + (ev.currentTarget as HTMLElement).style.color = 'var(--tl-label-text-dim)'; + }} + title="Delete track" + > + × +
+ + {/* Row 2: clip count + add clip + S/M buttons for audio */} +
+ + {clipCount} Clip{clipCount !== 1 ? 's' : ''} + +
{ + ev.stopPropagation(); + onAddClip(trackId); + }} + style={{ + fontSize: 9, + color: 'hsl(220 10% 55%)', + cursor: 'pointer', + padding: '0 3px', + borderRadius: 2, + background: 'transparent', + }} + onMouseEnter={(ev) => { + (ev.currentTarget as HTMLElement).style.color = 'hsl(213 70% 55%)'; + }} + onMouseLeave={(ev) => { + (ev.currentTarget as HTMLElement).style.color = 'hsl(220 10% 55%)'; + }} + title="Add clip to this track" + > + + Clip +
+
+ {isAudio && ( +
+
setSoloActive(!soloActive)} + style={{ + width: 20, + height: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 9, + fontFamily: 'monospace', + fontWeight: 700, + color: soloActive ? 'hsl(0 0% 10%)' : 'var(--tl-label-text)', + background: soloActive ? 'var(--tl-solo-active)' : 'hsl(220 13% 16%)', + borderRadius: 8, + cursor: 'pointer', + }} + > + S +
+
setMuteActive(!muteActive)} + style={{ + width: 20, + height: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 9, + fontFamily: 'monospace', + fontWeight: 700, + color: muteActive ? 'hsl(0 0% 100%)' : 'var(--tl-label-text)', + background: muteActive ? 'var(--tl-mute-active)' : 'hsl(220 13% 16%)', + borderRadius: 8, + cursor: 'pointer', + }} + > + M +
+
+ )} +
+
+ ); +} diff --git a/packages/ui/src/presets/davinci/icons.tsx b/packages/ui/src/presets/davinci/icons.tsx new file mode 100644 index 0000000..51032fc --- /dev/null +++ b/packages/ui/src/presets/davinci/icons.tsx @@ -0,0 +1,200 @@ +/** + * Tabler-based SVG icon components for the DaVinci preset. + * + * Sources: Tabler Icons (outline style, 24×24, stroke-1.5, round-cap). + * Missing icons (playback, zoom, trim) are hand-drawn in the same style. + */ +import React from 'react'; + +type IconProps = { size?: number; color?: string; strokeWidth?: number }; + +const defaults = { size: 16, color: 'currentColor', strokeWidth: 1.5 }; + +function svgProps(p: IconProps) { + const s = p.size ?? defaults.size; + const c = p.color ?? defaults.color; + const sw = p.strokeWidth ?? defaults.strokeWidth; + return { + xmlns: 'http://www.w3.org/2000/svg', + width: s, + height: s, + viewBox: '0 0 24 24', + fill: 'none', + stroke: c, + strokeWidth: sw, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + style: { display: 'block' } as React.CSSProperties, + }; +} + +// ── Tool icons ───────────────────────────────────────────────────────────── + +export function IconPointer(p: IconProps = {}) { + return ( + + + + + ); +} + +export function IconScissors(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +export function IconRippleTrim(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +export function IconRollTrim(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +export function IconSlip(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +export function IconSlide(p: IconProps = {}) { + const s = p.size ?? defaults.size; + const c = p.color ?? defaults.color; + return ( + + + + + + ); +} + +export function IconHand(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +// ── Playback icons ───────────────────────────────────────────────────── + +export function IconPlayerPlay(p: IconProps = {}) { + return ( + + + + + ); +} + +export function IconPlayerPause(p: IconProps = {}) { + return ( + + + + + + ); +} + +// ── Edit icons ───────────────────────────────────────────────────────── + +export function IconUndo(p: IconProps = {}) { + return ( + + + + + + ); +} + +export function IconRedo(p: IconProps = {}) { + return ( + + + + + + ); +} + +// ── Zoom icons ───────────────────────────────────────────────────────── + +export function IconZoomIn(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +export function IconZoomOut(p: IconProps = {}) { + return ( + + + + + + + ); +} + +// ── Exports (all icons keyed by tool id) ─────────────────────────────── + +export const TOOL_ICONS: Record React.JSX.Element> = { + selection: IconPointer, + razor: IconScissors, + 'ripple-trim': IconRippleTrim, + 'roll-trim': IconRollTrim, + slip: IconSlip, + slide: IconSlide, + hand: IconHand, +}; diff --git a/packages/ui/src/presets/davinci/index.ts b/packages/ui/src/presets/davinci/index.ts new file mode 100644 index 0000000..bbbb58c --- /dev/null +++ b/packages/ui/src/presets/davinci/index.ts @@ -0,0 +1,12 @@ +// DaVinci Resolve preset — all components +export { DaVinciEditor } from './davinci-editor'; +export type { DaVinciEditorProps } from './davinci-editor'; +export { DaVinciToolbar } from './davinci-toolbar'; +export { DaVinciRuler } from './davinci-ruler'; +export type { DaVinciRulerProps } from './davinci-ruler'; +export { DaVinciTrack } from './davinci-track'; +export type { DaVinciTrackProps } from './davinci-track'; +export { DaVinciClip } from './davinci-clip'; +export type { DaVinciClipProps } from './davinci-clip'; +export { DaVinciPlayhead } from './davinci-playhead'; +export type { DaVinciPlayheadProps } from './davinci-playhead'; diff --git a/packages/ui/src/shared/cn.ts b/packages/ui/src/shared/cn.ts new file mode 100644 index 0000000..ebfb082 --- /dev/null +++ b/packages/ui/src/shared/cn.ts @@ -0,0 +1,7 @@ +/** + * Simple class-name merge utility. + * Filters falsy values and joins with space. + */ +export function cn(...classes: (string | undefined | null | false)[]): string { + return classes.filter(Boolean).join(' '); +} diff --git a/packages/ui/src/shared/geometry.ts b/packages/ui/src/shared/geometry.ts new file mode 100644 index 0000000..a59d21b --- /dev/null +++ b/packages/ui/src/shared/geometry.ts @@ -0,0 +1,7 @@ +/** + * Geometry and math utilities. + */ + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/packages/ui/src/shared/time.ts b/packages/ui/src/shared/time.ts new file mode 100644 index 0000000..95e39d0 --- /dev/null +++ b/packages/ui/src/shared/time.ts @@ -0,0 +1,54 @@ +/** + * Frame / pixel / timecode math utilities. + * Extracted from proven demo logic. + */ + +export function frameToPx(frame: number, ppf: number, scrollLeft = 0): number { + return frame * ppf - scrollLeft; +} + +export function pxToFrame(x: number, ppf: number, scrollLeft = 0): number { + return Math.max(0, Math.round((x + scrollLeft) / ppf)); +} + +export function frameToTimecode(frame: number, fps: number): string { + const totalSeconds = Math.floor(frame / fps); + const f = Math.round(frame % fps); + const s = totalSeconds % 60; + const m = Math.floor(totalSeconds / 60) % 60; + const h = Math.floor(totalSeconds / 3600); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(h)}:${pad(m)}:${pad(s)}:${pad(f)}`; +} + +export function rulerTickInterval( + pixelsPerFrame: number, + fps: number, +): { major: number; minor: number } { + const LABEL_MIN_GAP = 120; + const TICK_MIN_GAP = 6; + + const candidates = [ + 1, 2, 5, 10, 15, 30, + fps, fps * 2, fps * 5, fps * 10, fps * 30, fps * 60, fps * 300, + fps * 600, fps * 1800, fps * 3600, + ]; + + const major = + candidates.find((c) => c * pixelsPerFrame >= LABEL_MIN_GAP) ?? + candidates[candidates.length - 1]!; + + let minor: number; + if (major >= fps) { + minor = major / (fps === 30 ? 5 : 4); + } else { + minor = Math.max(1, major / 5); + } + minor = Math.round(minor); + + while (minor * pixelsPerFrame < TICK_MIN_GAP && minor < major) { + minor *= 2; + } + + return { major, minor }; +} diff --git a/packages/ui/src/shared/use-refs.ts b/packages/ui/src/shared/use-refs.ts new file mode 100644 index 0000000..fefdc74 --- /dev/null +++ b/packages/ui/src/shared/use-refs.ts @@ -0,0 +1,37 @@ +/** + * Hook providing the proven ppfRef + scrollRef pattern. + * + * Keeps both a reactive state value and a mutable ref in sync. + * Pointer handlers read from the ref (never stale in closures), + * while React re-renders on state changes. + */ +import { useState, useRef, useCallback } from 'react'; + +export function useTimelineRefs(initialPpf = 4, initialScroll = 0) { + const [ppf, setPpfState] = useState(initialPpf); + const ppfRef = useRef(initialPpf); + + const [scrollLeft, setScrollState] = useState(initialScroll); + const scrollRef = useRef(initialScroll); + + const setPpf = useCallback((v: number) => { + const clamped = Math.max(0.5, Math.min(100, v)); + ppfRef.current = clamped; + setPpfState(clamped); + }, []); + + const setScrollLeft = useCallback((v: number) => { + const clamped = Math.max(0, v); + scrollRef.current = clamped; + setScrollState(clamped); + }, []); + + return { + ppf, + ppfRef, + setPpf, + scrollLeft, + scrollRef, + setScrollLeft, + }; +} diff --git a/packages/ui/src/timeline/Clip.tsx b/packages/ui/src/timeline/Clip.tsx deleted file mode 100644 index 0b2adb2..0000000 --- a/packages/ui/src/timeline/Clip.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import { useClip, useEngine } from '@timeline/react'; -import { frame, type Frame } from '@timeline/core'; -import { - findSnapTargets, - calculateSnapExcluding, - type SnapResult -} from '@timeline/core/internal'; -import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; - -type DragMode = 'move' | 'resize-left' | 'resize-right'; - -interface ClipProps { - clipId: string; - trackId: string; - pixelsPerFrame: number; - snappingEnabled?: boolean; - editingMode?: 'normal' | 'ripple' | 'insert'; - onSnapIndicator?: (frame: Frame | null) => void; - playhead?: Frame; - className?: string; - onDragStart?: (clipId: string) => void; - onDrag?: (clipId: string, deltaFrames: number) => void; - onDragEnd?: (clipId: string) => void; - isSelected?: boolean; - onClipClick?: (clipId: string, multiSelect: boolean) => void; - isLocked?: boolean; - onRequestTrackAtY?: (clientY: number, clipId: string) => { trackId: string; valid: boolean } | null; -} - -export function Clip({ - clipId, - trackId, - pixelsPerFrame, - snappingEnabled = false, - editingMode = 'normal', - onSnapIndicator, - playhead, - className = '', - onDragStart, - onDrag, - onDragEnd, - isSelected = false, - onClipClick, - isLocked = false, - onRequestTrackAtY, -}: ClipProps) { - const clip = useClip(clipId); - const engine = useEngine(); - const [isDragging, setIsDragging] = useState(false); - const [dragMode, setDragMode] = useState('move'); - const [targetTrackInfo, setTargetTrackInfo] = useState<{ trackId: string; valid: boolean } | null>(null); - const dragStartX = useRef(0); - const dragStartY = useRef(0); - const dragStartFrame = useRef(frame(0)); - const dragStartEnd = useRef(frame(0)); - const dragStartTrackId = useRef(''); - - if (!clip) { - return null; - } - - // Get asset information for display - memoized to avoid recalculation on every render - const asset = useMemo(() => engine.getAsset(clip.assetId), [engine, clip.assetId]); - - /** - * Extract display name from asset source URL - * - * Handles: - * - URLs with query strings: https://cdn.com/video.mp4?token=abc - * - URLs with fragments: file:///path/video.mp4#t=10 - * - File paths (Unix): /path/to/video.mp4 - * - File paths (Windows): C:\Users\Videos\video.mp4 - * - URLs without filenames: https://api.example.com/stream/123 - * - Dotfiles: .gitignore - * - * @returns Display name for the asset - */ - const getAssetDisplayName = useCallback((): string => { - if (!asset) { - console.warn(`Asset not found for clip ${clipId}`); - return `Clip ${clipId.slice(0, 8)}`; - } - - try { - // Try to parse as URL first - const url = new URL(asset.sourceUrl); - const pathname = url.pathname; - - // Extract filename from pathname - const segments = pathname.split('/').filter(Boolean); - const filename = segments[segments.length - 1] || ''; - - if (filename) { - // Remove file extension - const nameWithoutExt = filename.replace(/\.[^/.]+$/, ''); - return nameWithoutExt || filename; - } - - // No filename in URL, use asset type as fallback - return asset.type.toUpperCase(); - - } catch { - // Not a valid URL, treat as file path - // Handle both Unix (/) and Windows (\) path separators - const parts = asset.sourceUrl.split(/[/\\]/).filter(Boolean); - const filename = parts[parts.length - 1] || ''; - - if (filename) { - // Remove file extension - const nameWithoutExt = filename.replace(/\.[^/.]+$/, ''); - // Handle dotfiles (e.g., .gitignore -> show full name) - return nameWithoutExt || filename; - } - - // Fallback to asset type if no recognizable filename - return asset.type.toUpperCase(); - } - }, [asset, clipId]); - - /** - * Get icon emoji based on asset type - * - * @returns Emoji representing the asset type - */ - const getAssetTypeIcon = useCallback((): string => { - if (!asset) return '📄'; - - switch (asset.type) { - case 'video': - return '🎬'; - case 'audio': - return '🎵'; - case 'image': - return '🖼️'; - default: - return '📄'; - } - }, [asset]); - - // Memoize visual calculations to avoid recalculation on every render - const width = useMemo( - () => (clip.timelineEnd - clip.timelineStart) * pixelsPerFrame, - [clip.timelineStart, clip.timelineEnd, pixelsPerFrame] - ); - - const left = useMemo( - () => clip.timelineStart * pixelsPerFrame, - [clip.timelineStart, pixelsPerFrame] - ); - - const applySnapping = (proposedFrame: Frame): Frame => { - if (!snappingEnabled) { - onSnapIndicator?.(null); - return proposedFrame; - } - - const state = engine.getState(); - const targets = findSnapTargets(state, playhead); - const snapResult = calculateSnapExcluding( - proposedFrame, - targets, - frame(5), // 5 frame snap threshold - [clipId] - ); - - if (snapResult.snapped) { - onSnapIndicator?.(snapResult.snappedFrame); - return snapResult.snappedFrame; - } else { - onSnapIndicator?.(null); - return proposedFrame; - } - }; - - const handleMouseDown = (e: React.MouseEvent, mode: DragMode) => { - e.preventDefault(); - e.stopPropagation(); - - // Prevent dragging/resizing if track is locked - if (isLocked) { - return; - } - - // Handle selection on click - if (mode === 'move') { - onClipClick?.(clipId, e.shiftKey || e.metaKey || e.ctrlKey); - } - - setIsDragging(true); - setDragMode(mode); - dragStartX.current = e.clientX; - dragStartY.current = e.clientY; - dragStartFrame.current = clip.timelineStart; - dragStartEnd.current = clip.timelineEnd; - dragStartTrackId.current = trackId; - onDragStart?.(clipId); - }; - - useEffect(() => { - if (!isDragging) return; - - let lastValidStart = dragStartFrame.current; - let lastValidEnd = dragStartEnd.current; - let rafId: number | null = null; - - const performDragOperation = (e: MouseEvent) => { - const deltaX = e.clientX - dragStartX.current; - const deltaY = e.clientY - dragStartY.current; - const deltaFrames = Math.round(deltaX / pixelsPerFrame); - - try { - if (dragMode === 'resize-left') { - // Resize from left: adjust timelineStart - const proposedStart = frame(dragStartFrame.current + deltaFrames); - const snappedStart = applySnapping(proposedStart); - - // Only update if different from last valid state - if (snappedStart !== lastValidStart) { - engine.resizeClip(clipId, snappedStart, clip.timelineEnd); - lastValidStart = snappedStart; - } - } else if (dragMode === 'resize-right') { - // Resize from right: adjust timelineEnd - const proposedEnd = frame(dragStartEnd.current + deltaFrames); - const snappedEnd = applySnapping(proposedEnd); - - // Only update if different from last valid state - if (snappedEnd !== lastValidEnd) { - engine.resizeClip(clipId, clip.timelineStart, snappedEnd); - lastValidEnd = snappedEnd; - } - } else { - // Move clip - check for vertical movement - const isVerticalDrag = Math.abs(deltaY) > 10; // Threshold for vertical drag - - if (isVerticalDrag && onRequestTrackAtY) { - // Vertical drag - check target track - const trackInfo = onRequestTrackAtY(e.clientY, clipId); - setTargetTrackInfo(trackInfo); - - // Don't perform horizontal movement during vertical drag - return; - } else { - // Horizontal drag only - setTargetTrackInfo(null); - - const proposedStart = frame(dragStartFrame.current + deltaFrames); - const snappedStart = applySnapping(proposedStart); - - // Only update if different from last valid state - if (snappedStart !== lastValidStart) { - onDrag?.(clipId, snappedStart - dragStartFrame.current); - - // Use appropriate move method based on editing mode - if (editingMode === 'normal') { - engine.moveClip(clipId, snappedStart); - } else if (editingMode === 'ripple') { - // Use core rippleMove operation for atomic transaction - engine.rippleMove(clipId, snappedStart); - } else if (editingMode === 'insert') { - // Use core insertMove operation for atomic transaction - engine.insertMove(clipId, snappedStart); - } - - lastValidStart = snappedStart; - } - } - } - } catch (error) { - // Validation error - clip can't be moved/resized there - console.warn('Cannot perform operation:', error); - } - - rafId = null; - }; - - const handleMouseMove = (e: MouseEvent) => { - // Throttle with requestAnimationFrame to prevent excessive updates - if (rafId === null) { - rafId = requestAnimationFrame(() => performDragOperation(e)); - } - }; - - const handleMouseUp = (e: MouseEvent) => { - // Check if we were doing a vertical drag - const deltaY = e.clientY - dragStartY.current; - const isVerticalDrag = Math.abs(deltaY) > 10; - - if (isVerticalDrag && dragMode === 'move' && onRequestTrackAtY) { - const trackInfo = onRequestTrackAtY(e.clientY, clipId); - - if (trackInfo && trackInfo.valid && trackInfo.trackId !== dragStartTrackId.current) { - // Attempt to move clip to new track - const result = engine.moveClipToTrack(clipId, trackInfo.trackId); - - if (!result.success) { - console.warn('Failed to move clip to track:', result.errors); - } - } - } - - setIsDragging(false); - setTargetTrackInfo(null); - onSnapIndicator?.(null); - onDragEnd?.(clipId); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - - // Cancel any pending RAF to prevent memory leaks - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - }; - }, [isDragging, dragMode, clipId, pixelsPerFrame, clip, engine, snappingEnabled, editingMode, playhead, onDrag, onDragEnd, onSnapIndicator, isLocked, onRequestTrackAtY]); - - return ( -
- {/* Left resize handle */} - {!isLocked && ( -
handleMouseDown(e, 'resize-left')} - title="Resize left" - > - {/* Larger hit area */} -
-
- )} - - {/* Clip content */} -
handleMouseDown(e, 'move')} - > -
- - {getAssetTypeIcon()} - -
-
- {getAssetDisplayName()} -
- {asset && width > 80 && ( -
- {asset.type} • {clip.timelineEnd - clip.timelineStart}f -
- )} -
-
-
- - {/* Right resize handle */} - {!isLocked && ( -
handleMouseDown(e, 'resize-right')} - title="Resize right" - > - {/* Larger hit area */} -
-
- )} -
- ); -} diff --git a/packages/ui/src/timeline/TimeRuler.tsx b/packages/ui/src/timeline/TimeRuler.tsx deleted file mode 100644 index f4d2ca5..0000000 --- a/packages/ui/src/timeline/TimeRuler.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { frame, type Frame, type FrameRate } from '@timeline/core'; - -interface TimeRulerProps { - duration: Frame; - fps: FrameRate; - pixelsPerFrame: number; - className?: string; - onRulerClick?: (frame: Frame) => void; -} - -export function TimeRuler({ duration, fps, pixelsPerFrame, className = '', onRulerClick }: TimeRulerProps) { - const totalWidth = duration * pixelsPerFrame; - - // Calculate tick interval based on zoom level - const getTickInterval = (): number => { - if (pixelsPerFrame >= 2) return fps; // Every second when zoomed in - if (pixelsPerFrame >= 0.5) return fps * 5; // Every 5 seconds - return fps * 10; // Every 10 seconds when zoomed out - }; - - const tickInterval = getTickInterval(); - const ticks: number[] = []; - - for (let f = 0; f <= duration; f += tickInterval) { - ticks.push(f); - } - - const formatTime = (f: number): string => { - const totalSeconds = Math.floor(f / fps); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; - }; - - // Handle click to move playhead - const handleClick = (e: React.MouseEvent) => { - if (!onRulerClick) return; - - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const clickedFrame = frame(Math.round(clickX / pixelsPerFrame)); - - // Clamp to valid range - const clampedFrame = frame(Math.max(0, Math.min(duration, clickedFrame))); - onRulerClick(clampedFrame); - }; - - return ( -
- {/* Left spacer to align with track labels */} -
- - {/* Time ruler content */} -
- {ticks.map((f) => ( -
- - {formatTime(f)} - -
- ))} -
-
- ); -} diff --git a/packages/ui/src/timeline/Timeline.tsx b/packages/ui/src/timeline/Timeline.tsx deleted file mode 100644 index 2c7967a..0000000 --- a/packages/ui/src/timeline/Timeline.tsx +++ /dev/null @@ -1,575 +0,0 @@ -import { useTimeline, useEngine } from '@timeline/react'; -import { TimeRuler } from './TimeRuler'; -import { Track } from './Track'; -import { frame, type Frame } from '@timeline/core'; -import { useState, useEffect, useRef } from 'react'; -import { useTimelineUI, TimelineUIProvider, type TimelineUIProviderProps } from '../context/TimelineUIContext'; - -interface TimelineProps { - className?: string; - onClipMove?: (clipId: string, newStart: Frame) => void; - onClipResize?: (clipId: string, newStart: Frame, newEnd: Frame) => void; -} - -/** - * Internal Timeline component that uses TimelineUIContext - */ -function TimelineInner({ className = '', onClipMove, onClipResize }: TimelineProps) { - const { state } = useTimeline(); - const engine = useEngine(); - const { state: uiState, actions: uiActions } = useTimelineUI(); - - // Local state that doesn't belong in context - const [snapIndicator, setSnapIndicator] = useState(null); - const [copiedClips, setCopiedClips] = useState>([]); - const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false); - const timelineRef = useRef(null); - - const { timeline } = state; - - // Destructure UI state and actions for convenience - const { playhead, zoom: pixelsPerFrame, snappingEnabled, editingMode, selectedClipIds } = uiState; - const { setPlayhead, setZoom: setPixelsPerFrame, setSnappingEnabled, setEditingMode, setSelectedClipIds } = uiActions; - - // Keyboard handler for playhead movement, clip selection, deletion, copy/paste, and undo/redo - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Undo/Redo work globally - if ((e.metaKey || e.ctrlKey) && e.key === 'z') { - e.preventDefault(); - if (e.shiftKey) { - engine.redo(); - } else { - engine.undo(); - } - return; - } - - // Only handle other keys when timeline is focused - if (!timelineRef.current?.contains(document.activeElement)) { - return; - } - - switch (e.key) { - case 'ArrowLeft': - e.preventDefault(); - // Jump by 10 frames with Shift, 1 frame otherwise - const leftAmount = e.shiftKey ? 10 : 1; - setPlayhead(prev => frame(Math.max(0, prev - leftAmount))); - break; - case 'ArrowRight': - e.preventDefault(); - // Jump by 10 frames with Shift, 1 frame otherwise - const rightAmount = e.shiftKey ? 10 : 1; - setPlayhead(prev => frame(Math.min(timeline.duration, prev + rightAmount))); - break; - case 'Home': - e.preventDefault(); - setPlayhead(frame(0)); - break; - case 'End': - e.preventDefault(); - setPlayhead(frame(timeline.duration)); - break; - case 'Delete': - case 'Backspace': - e.preventDefault(); - // Delete selected clips (ripple mode if enabled) - selectedClipIds.forEach(clipId => { - try { - if (editingMode === 'ripple') { - engine.rippleDelete(clipId); - } else { - engine.removeClip(clipId); - } - } catch (error) { - console.warn('Failed to delete clip:', error); - } - }); - uiActions.clearSelection(); - break; - case 'c': - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - // Copy selected clips - const clipsToCopy: Array<{clipId: string, trackId: string}> = []; - selectedClipIds.forEach(clipId => { - const clip = engine.findClipById(clipId); - if (clip) { - clipsToCopy.push({ clipId, trackId: clip.trackId }); - } - }); - setCopiedClips(clipsToCopy); - } - break; - case 'v': - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - // Paste clips at playhead - copiedClips.forEach(({ clipId, trackId }) => { - try { - const originalClip = engine.findClipById(clipId); - if (originalClip) { - const duration = originalClip.timelineEnd - originalClip.timelineStart; - const newClip = { - ...originalClip, - id: `clip-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - timelineStart: playhead, - timelineEnd: frame(playhead + duration), - }; - if (editingMode === 'insert') { - engine.insertEdit(trackId, newClip, playhead); - } else { - engine.addClip(trackId, newClip); - } - } - } catch (error) { - console.warn('Failed to paste clip:', error); - } - }); - } - break; - case 'a': - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - // Select all clips - const allClipIds = new Set(); - state.timeline.tracks.forEach(track => { - track.clips.forEach(clip => { - allClipIds.add(clip.id); - }); - }); - uiActions.setSelectedClipIds(allClipIds); - } - break; - case 'Escape': - e.preventDefault(); - // Deselect all - uiActions.clearSelection(); - break; - case 'm': - case 'M': - e.preventDefault(); - // Add marker at playhead - { - const marker = { - id: `marker-${Date.now()}`, - type: 'timeline' as const, - frame: playhead, - label: `Mark ${playhead}`, - color: '#10b981', - }; - engine.addTimelineMarker(marker); - } - break; - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [timeline.duration, selectedClipIds, copiedClips, playhead, engine, state.timeline.tracks]); - - // Handle TimeRuler click to move playhead - const handleRulerClick = (clickedFrame: Frame) => { - setPlayhead(clickedFrame); - }; - - // Handle playhead drag start - const handlePlayheadMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDraggingPlayhead(true); - }; - - // Handle playhead drag - useEffect(() => { - if (!isDraggingPlayhead) return; - - const handleMouseMove = (e: MouseEvent) => { - if (!timelineRef.current) return; - - const rect = timelineRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left - 128; // Offset by track label width - const newFrame = Math.round(x / pixelsPerFrame); - const clampedFrame = Math.max(0, Math.min(timeline.duration, newFrame)); - setPlayhead(frame(clampedFrame)); - }; - - const handleMouseUp = () => { - setIsDraggingPlayhead(false); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isDraggingPlayhead, pixelsPerFrame, timeline.duration]); - - // Handle clip selection - const handleClipClick = (clipId: string, multiSelect: boolean) => { - if (multiSelect) { - // Toggle selection - uiActions.toggleClipSelection(clipId); - } else { - // Single selection - setSelectedClipIds(new Set([clipId])); - } - }; - - // Handle track lookup at Y position with validation - const handleRequestTrackAtY = (clientY: number, clipId: string): { trackId: string; valid: boolean } | null => { - if (!timelineRef.current) return null; - - // Find the clip to check its asset type - const clip = engine.findClipById(clipId); - if (!clip) return null; - - const asset = engine.getAsset(clip.assetId); - if (!asset) return null; - - // Find all track elements - const trackElements = timelineRef.current.querySelectorAll('[data-track-id]'); - - for (const element of Array.from(trackElements)) { - const rect = element.getBoundingClientRect(); - - if (clientY >= rect.top && clientY <= rect.bottom) { - const targetTrackId = element.getAttribute('data-track-id'); - - if (!targetTrackId) continue; - - const targetTrack = state.timeline.tracks.find(t => t.id === targetTrackId); - - if (!targetTrack) continue; - - // Validate track type match - let valid = !targetTrack.locked; - - if (valid) { - // Check type compatibility - if (asset.type === 'video' && targetTrack.type !== 'video') { - valid = false; - } else if (asset.type === 'audio' && targetTrack.type !== 'audio') { - valid = false; - } else if (asset.type === 'image' && targetTrack.type !== 'video') { - valid = false; - } - } - - return { - trackId: targetTrackId, - valid, - }; - } - } - - return null; - }; - - return ( -
- {/* Controls */} -
- {/* Undo/Redo buttons */} -
- - -
- - {/* Zoom controls */} -
- Zoom: - - - {pixelsPerFrame.toFixed(1)}x - - -
- - {/* Snapping toggle */} -
- Snap: - -
- - {/* Editing mode selector */} -
- Mode: - - {editingMode === 'ripple' && ( - - ⚡ Ripple - - )} - {editingMode === 'insert' && ( - - ➕ Insert - - )} -
- - {/* Marker controls */} -
- - {state.workArea ? ( - - ) : ( - - )} -
-
- - {/* Timeline content */} -
- {/* Time ruler */} -
- -
- - {/* Tracks */} -
- {timeline.tracks.map((track) => ( - - ))} - - {/* Work Area */} - {state.workArea && ( -
-
- Work Area -
-
- )} - - {/* Timeline Markers */} - {state.markers.timeline.map((marker) => ( -
-
-
-
- {marker.label} -
-
- ))} - - {/* Region Markers */} - {state.markers.regions.map((marker) => ( -
-
- {marker.label} -
-
- ))} - - {/* Snap indicator */} - {snapIndicator !== null && ( -
- )} - - {/* Playhead */} -
- {/* Playhead handle for easier grabbing */} -
-
-
- - {/* Empty state */} - {timeline.tracks.length === 0 && ( -
- No tracks yet -
- )} -
-
- ); -} - -/** - * Timeline component with built-in UI state management - * - * This component wraps TimelineInner with TimelineUIProvider to manage - * UI state (playhead, zoom, snapping, selection, editing mode). - * - * The UI state is separate from the engine state and is accessible - * to external components via useTimelineUI() hook. - */ -export interface TimelineComponentProps extends TimelineProps { - /** Initial playhead position (default: frame(0)) */ - initialPlayhead?: Frame; - /** Initial zoom level in pixels per frame (default: 1) */ - initialZoom?: number; - /** Initial snapping state (default: true) */ - initialSnappingEnabled?: boolean; - /** Initial editing mode (default: 'normal') */ - initialEditingMode?: 'normal' | 'ripple' | 'insert'; -} - -export function Timeline({ - initialPlayhead, - initialZoom, - initialSnappingEnabled, - initialEditingMode, - ...timelineProps -}: TimelineComponentProps) { - return ( - - - - ); -} diff --git a/packages/ui/src/timeline/Track.tsx b/packages/ui/src/timeline/Track.tsx deleted file mode 100644 index f4e5621..0000000 --- a/packages/ui/src/timeline/Track.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useTrack, useEngine } from '@timeline/react'; -import { Clip } from './Clip'; -import type { Frame } from '@timeline/core'; -import { useState, useEffect } from 'react'; - -interface TrackProps { - trackId: string; - pixelsPerFrame: number; - snappingEnabled?: boolean; - editingMode?: 'normal' | 'ripple' | 'insert'; - onSnapIndicator?: (frame: Frame | null) => void; - playhead?: Frame; - className?: string; - selectedClipIds?: Set; - onClipClick?: (clipId: string, multiSelect: boolean) => void; - onRequestTrackAtY?: (clientY: number, clipId: string) => { trackId: string; valid: boolean } | null; -} - -export function Track({ - trackId, - pixelsPerFrame, - snappingEnabled, - editingMode, - onSnapIndicator, - playhead, - className = '', - selectedClipIds = new Set(), - onClipClick, - onRequestTrackAtY, -}: TrackProps) { - const track = useTrack(trackId); - const engine = useEngine(); - const [isResizing, setIsResizing] = useState(false); - const [startY, setStartY] = useState(0); - const [startHeight, setStartHeight] = useState(0); - - if (!track) { - return null; - } - - const handleToggleMute = () => { - engine.toggleTrackMute(trackId); - }; - - const handleToggleLock = () => { - engine.toggleTrackLock(trackId); - }; - - const handleToggleSolo = () => { - engine.toggleTrackSolo(trackId); - }; - - const handleDeleteTrack = () => { - if (window.confirm(`Delete track "${track.name}"?`)) { - engine.removeTrack(trackId); - } - }; - - const handleResizeStart = (e: React.MouseEvent) => { - e.preventDefault(); - setIsResizing(true); - setStartY(e.clientY); - setStartHeight(track.height); - }; - - useEffect(() => { - if (!isResizing) return; - - const handleMouseMove = (e: MouseEvent) => { - const deltaY = e.clientY - startY; - const newHeight = Math.max(40, Math.min(200, startHeight + deltaY)); - engine.setTrackHeight(trackId, newHeight); - }; - - const handleMouseUp = () => { - setIsResizing(false); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isResizing, startY, startHeight, trackId, engine]); - - return ( -
- {/* Track label */} -
-
-
- - {track.type === 'video' ? '📹' : '🎵'} - - {track.name} -
- -
-
- - - -
-
- - {/* Track content area */} -
- {/* Background grid */} -
- {/* Grid lines could go here if needed */} -
- - {/* Clips */} - {track.clips.map((clip) => ( - - ))} -
- - {/* Draggable resize handle at bottom */} -
-
- ); -} diff --git a/packages/ui/src/timeline/index.ts b/packages/ui/src/timeline/index.ts deleted file mode 100644 index 2374975..0000000 --- a/packages/ui/src/timeline/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { Timeline } from './Timeline'; -export { Track } from './Track'; -export { Clip } from './Clip'; -export { TimeRuler } from './TimeRuler'; diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css new file mode 100644 index 0000000..eb848be --- /dev/null +++ b/packages/ui/src/tokens.css @@ -0,0 +1,92 @@ +/* ═══════════════════════════════════════════════════════════ + @webpacked-timeline/ui — Base CSS Variable Tokens + + Every color, size, and spacing used by components references + these variables. Override any variable to create a custom theme. + ═══════════════════════════════════════════════════════════ */ + +:root { + /* ── App chrome ── */ + --tl-app-bg: hsl(220 13% 9%); + --tl-panel-bg: hsl(220 13% 11%); + --tl-toolbar-bg: hsl(220 13% 11%); + --tl-toolbar-border: hsl(220 13% 18%); + --tl-toolbar-height: 40px; + + /* ── Ruler ── */ + --tl-ruler-bg: hsl(220 13% 8%); + --tl-ruler-height: 32px; + --tl-ruler-tick: hsl(220 13% 30%); + --tl-ruler-tick-maj: hsl(220 13% 48%); + --tl-ruler-text: hsl(220 10% 52%); + + /* ── Timecode ── */ + --tl-timecode-color: hsl(0 0% 88%); + --tl-timecode-size: 15px; + + /* ── Track ── */ + --tl-track-bg: hsl(220 13% 13%); + --tl-track-bg-video: #28282E; + --tl-track-bg-audio: #28282E; + --tl-track-border: hsl(220 13% 19%); + --tl-track-height: 80px; + --tl-track-height-audio: 80px; + + /* ── Label ── */ + --tl-label-width: 200px; + --tl-label-bg: hsl(220 13% 10%); + --tl-label-text: hsl(220 10% 62%); + --tl-label-text-dim: hsl(220 10% 45%); + --tl-label-border: hsl(220 13% 19%); + + /* ── Clips ── */ + --tl-clip-video-bg: #2E77A5; + --tl-clip-video-top: #2E77A5; + --tl-clip-audio-bg: #179160; + --tl-clip-audio-top: #179160; + --tl-clip-selected: #3da2ff; + --tl-clip-provisional:rgba(46,119,165,0.5); + --tl-clip-border: rgba(255,255,255,0.15); + --tl-clip-border-sel: #6fbfff; + --tl-clip-text: hsl(0 0% 92%); + --tl-clip-text-dim: hsl(0 0% 65%); + --tl-clip-video-text: hsl(195 85% 15%); + --tl-clip-audio-text: hsl(142 70% 12%); + --tl-clip-radius: 2px; + --tl-clip-video-accent: hsl(195 85% 72%); + --tl-clip-audio-accent:#179160; + + /* ── Playhead ── */ + --tl-playhead-color: #ff3b30; + --tl-playhead-width: 1px; + + /* ── Snap ── */ + --tl-snap-color: hsl(45 90% 60%); + + /* ── Toolbar buttons ── */ + --tl-btn-bg: transparent; + --tl-btn-bg-hover: hsl(220 13% 20%); + --tl-btn-bg-active: hsl(213 45% 32%); + --tl-btn-border: hsl(220 13% 22%); + --tl-btn-border-active:#3da2ff; + --tl-btn-text: hsl(220 10% 68%); + --tl-btn-text-active: #ffffff; + + /* ── Type colors ── */ + --tl-type-video: #2E77A5; + --tl-type-audio: #179160; + --tl-type-subtitle: #d4a62a; + --tl-type-title: #9a58d4; + + /* ── Solo / Mute ── */ + --tl-solo-active: #ffd54f; + --tl-mute-active: #ff5252; + + /* ── Resize ── */ + --tl-resize-handle: hsl(220 13% 26%); + + /* ── Scrollbar ── */ + --tl-scrollbar-track: hsl(220 13% 9%); + --tl-scrollbar-thumb: hsl(220 13% 24%); + --tl-scrollbar-thumb-hover: hsl(220 13% 32%); +} diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js deleted file mode 100644 index a8ee778..0000000 --- a/packages/ui/tailwind.config.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Tailwind CSS Preset for @timeline/ui - * - * This preset includes the color palette and utilities used by - * the timeline UI components. - * - * Usage in your tailwind.config.js: - * - * ```js - * module.exports = { - * presets: [ - * require('@timeline/ui/tailwind.config.js') - * ], - * content: [ - * './node_modules/@timeline/ui/dist/**\/*.{js,ts,jsx,tsx}', - * // ... your content paths - * ], - * } - * ``` - */ - -/** @type {import('tailwindcss').Config} */ -module.exports = { - theme: { - extend: { - colors: { - // Timeline UI uses the zinc color palette extensively - // These are already part of Tailwind's default theme, - // but we document them here for clarity - zinc: { - 50: '#fafafa', - 100: '#f4f4f5', - 200: '#e4e4e7', - 300: '#d4d4d8', - 400: '#a1a1aa', - 500: '#71717a', - 600: '#52525b', - 700: '#3f3f46', - 800: '#27272a', - 900: '#18181b', - 950: '#09090b', - }, - }, - // Timeline-specific utilities can be added here - spacing: { - // Any custom spacing values used by timeline components - }, - }, - }, - plugins: [], -}; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 5bebdf1..542a7a9 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -2,20 +2,21 @@ "compilerOptions": { "target": "ES2020", "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", "jsx": "react-jsx", + "strict": true, "declaration": true, "declarationMap": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", "esModuleInterop": true, "skipLibCheck": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], "forceConsistentCasingInFileNames": true, - "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true }, - "include": ["src"], + "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts new file mode 100644 index 0000000..043331f --- /dev/null +++ b/packages/ui/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'TimelineUI', + formats: ['es', 'cjs'], + fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), + }, + rollupOptions: { + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + '@webpacked-timeline/core', + '@webpacked-timeline/react', + ], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + }, + }, + cssCodeSplit: false, + }, +}); diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts deleted file mode 100644 index eacbfa7..0000000 --- a/packages/ui/vitest.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'jsdom', - setupFiles: ['./src/__tests__/setup.ts'], - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/', - 'dist/', - '**/*.d.ts', - '**/*.config.*', - '**/__tests__/setup.ts', - ], - }, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e4dab1..858109f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,13 +17,13 @@ importers: apps/demo: dependencies: - '@timeline/core': + '@webpacked-timeline/core': specifier: workspace:* version: link:../../packages/core - '@timeline/react': + '@webpacked-timeline/react': specifier: workspace:* version: link:../../packages/react - '@timeline/ui': + '@webpacked-timeline/ui': specifier: workspace:* version: link:../../packages/ui react: @@ -33,6 +33,9 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.2.1(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1)) '@types/react': specifier: ^18.2.0 version: 18.3.28 @@ -41,22 +44,16 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitejs/plugin-react': specifier: ^4.2.0 - version: 4.7.0(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.30.2)) - autoprefixer: - specifier: ^10.4.16 - version: 10.4.24(postcss@8.5.6) - postcss: - specifier: ^8.4.32 - version: 8.5.6 + version: 4.7.0(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1)) tailwindcss: - specifier: ^3.4.0 - version: 3.4.4 + specifier: ^4.0.0 + version: 4.2.1 typescript: specifier: ^5.3.0 version: 5.9.3 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@24.10.11)(lightningcss@1.30.2) + version: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) packages/core: devDependencies: @@ -65,7 +62,7 @@ importers: version: 24.10.11 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.30.2)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1)) tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -77,11 +74,11 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.30.2) + version: 2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1) packages/react: dependencies: - '@timeline/core': + '@webpacked-timeline/core': specifier: workspace:* version: link:../core devDependencies: @@ -99,7 +96,7 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.30.2)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1)) jsdom: specifier: ^25.0.1 version: 25.0.1 @@ -117,60 +114,43 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.30.2) + version: 2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1) packages/ui: - dependencies: - '@timeline/core': + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.35 + '@types/react': + specifier: ^18.0.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.0.0 + version: 18.3.7(@types/react@18.3.28) + '@webpacked-timeline/core': specifier: workspace:* version: link:../core - '@timeline/react': + '@webpacked-timeline/react': specifier: workspace:* version: link:../react react: - specifier: ^18.0.0 + specifier: ^18.3.1 version: 18.3.1 react-dom: - specifier: ^18.0.0 + specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - tailwindcss: - specifier: ^3.0.0 - version: 3.4.4 - devDependencies: - '@testing-library/jest-dom': - specifier: ^6.6.3 - version: 6.9.1 - '@testing-library/react': - specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/react': - specifier: ^18.0.0 - version: 18.3.28 - '@types/react-dom': - specifier: ^18.0.0 - version: 18.3.7(@types/react@18.3.28) - '@vitest/coverage-v8': - specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.30.2)) - jsdom: - specifier: ^25.0.1 - version: 25.0.1 typescript: specifier: ^5.0.0 version: 5.9.3 - vitest: - specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.30.2) + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@20.19.35)(lightningcss@1.31.1) packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} - '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -841,6 +821,100 @@ packages: cpu: [x64] os: [win32] + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -885,6 +959,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@20.19.35': + resolution: {integrity: sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==} + '@types/node@24.10.11': resolution: {integrity: sha512-/Af7O8r1frCVgOz0I62jWUtMohJ0/ZQU/ZoketltOJPZpnb17yoNc9BSoVuV9qlaIXJiPNOpsfq4ByFajSArNQ==} @@ -979,13 +1056,6 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -1010,13 +1080,6 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - autoprefixer@10.4.24: - resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1028,10 +1091,6 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -1058,10 +1117,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} @@ -1076,10 +1131,6 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -1120,11 +1171,6 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -1168,16 +1214,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1200,6 +1240,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -1291,9 +1335,6 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1329,10 +1370,6 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1400,14 +1437,6 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1457,10 +1486,6 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1502,84 +1527,80 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} - lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1678,10 +1699,6 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} @@ -1689,10 +1706,6 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -1733,9 +1746,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -1765,10 +1775,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -1780,30 +1786,6 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - postcss-import@15.1.0: - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - - postcss-js@4.1.0: - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - - postcss-load-config@4.0.2: - resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} - engines: {node: '>= 14'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -1822,19 +1804,6 @@ packages: yaml: optional: true - postcss-nested@6.2.0: - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1874,17 +1843,10 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} - read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -1900,11 +1862,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2014,17 +1971,15 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tailwindcss@3.4.4: - resolution: {integrity: sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==} - engines: {node: '>=14.0.0'} - hasBin: true + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -2155,6 +2110,9 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2168,9 +2126,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2302,8 +2257,6 @@ snapshots: '@adobe/css-tools@4.4.4': {} - '@alloc/quick-lru@5.2.0': {} - '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2889,6 +2842,74 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -2946,6 +2967,10 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@20.19.35': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.11': dependencies: undici-types: 7.16.0 @@ -2961,7 +2986,7 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.30.2))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -2969,11 +2994,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.30.2) + vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.30.2))': + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -2987,7 +3012,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.30.2) + vitest: 2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1) transitivePeerDependencies: - supports-color @@ -2998,13 +3023,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.30.2))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.30.2) + vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) '@vitest/pretty-format@2.1.9': dependencies: @@ -3051,13 +3076,6 @@ snapshots: any-promise@1.3.0: {} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - arg@5.0.2: {} - argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -3076,15 +3094,6 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.4.24(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - caniuse-lite: 1.0.30001769 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - balanced-match@1.0.2: {} baseline-browser-mapping@2.9.19: {} @@ -3093,8 +3102,6 @@ snapshots: dependencies: is-windows: 1.0.2 - binary-extensions@2.3.0: {} - brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -3123,8 +3130,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001769: {} chai@5.3.3: @@ -3139,18 +3144,6 @@ snapshots: check-error@2.1.3: {} - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -3183,8 +3176,6 @@ snapshots: css.escape@1.5.1: {} - cssesc@3.0.0: {} - cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -3211,17 +3202,12 @@ snapshots: detect-indent@6.1.0: {} - detect-libc@2.1.2: - optional: true - - didyoumean@1.2.2: {} + detect-libc@2.1.2: {} dir-glob@3.0.1: dependencies: path-type: 4.0.0 - dlv@1.1.3: {} - dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -3240,6 +3226,11 @@ snapshots: emoji-regex@9.2.2: {} + enhanced-resolve@5.20.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -3375,8 +3366,6 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - fraction.js@5.3.4: {} - fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -3422,10 +3411,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -3494,14 +3479,6 @@ snapshots: indent-string@4.0.0: {} - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -3549,10 +3526,7 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jiti@1.21.7: {} - - jiti@2.6.1: - optional: true + jiti@2.6.1: {} joycon@3.1.1: {} @@ -3603,57 +3577,54 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.31.1: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.31.1: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.31.1: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.31.1: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.31.1: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.31.1: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.31.1: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.31.1: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.31.1: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.31.1: optional: true - lightningcss@1.30.2: + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 - optional: true - - lilconfig@2.1.0: {} + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 lilconfig@3.1.3: {} @@ -3739,14 +3710,10 @@ snapshots: node-releases@2.0.27: {} - normalize-path@3.0.0: {} - nwsapi@2.2.23: {} object-assign@4.1.1: {} - object-hash@3.0.0: {} - outdent@0.5.0: {} p-filter@2.1.0: @@ -3779,8 +3746,6 @@ snapshots: path-key@3.1.1: {} - path-parse@1.0.7: {} - path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -3800,8 +3765,6 @@ snapshots: picomatch@4.0.3: {} - pify@2.3.0: {} - pify@4.0.1: {} pirates@4.0.7: {} @@ -3812,25 +3775,6 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - postcss-import@15.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.11 - - postcss-js@4.1.0(postcss@8.5.6): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.6 - - postcss-load-config@4.0.2(postcss@8.5.6): - dependencies: - lilconfig: 3.1.3 - yaml: 2.8.2 - optionalDependencies: - postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 @@ -3840,18 +3784,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - postcss-nested@6.2.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-selector-parser: 6.1.2 - - postcss-selector-parser@6.1.2: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-value-parser@4.2.0: {} - postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -3886,10 +3818,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - read-cache@1.0.0: - dependencies: - pify: 2.3.0 - read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -3897,10 +3825,6 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 - readdirp@3.6.0: - dependencies: - picomatch: 2.3.1 - readdirp@4.1.2: {} redent@3.0.0: @@ -3912,12 +3836,6 @@ snapshots: resolve-pkg-maps@1.0.0: {} - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - reusify@1.1.0: {} rollup@4.57.1: @@ -4040,36 +3958,11 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} - symbol-tree@3.2.4: {} - tailwindcss@3.4.4: - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 2.1.0 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-import: 15.1.0(postcss@8.5.6) - postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6) - postcss-nested: 6.2.0(postcss@8.5.6) - postcss-selector-parser: 6.1.2 - resolve: 1.22.11 - sucrase: 3.35.1 - transitivePeerDependencies: - - ts-node + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} term-size@2.2.1: {} @@ -4190,6 +4083,8 @@ snapshots: ufo@1.6.3: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} universalify@0.1.2: {} @@ -4200,15 +4095,13 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - util-deprecate@1.0.2: {} - - vite-node@2.1.9(@types/node@24.10.11)(lightningcss@1.30.2): + vite-node@2.1.9(@types/node@24.10.11)(lightningcss@1.31.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.30.2) + vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) transitivePeerDependencies: - '@types/node' - less @@ -4220,7 +4113,17 @@ snapshots: - supports-color - terser - vite@5.4.21(@types/node@24.10.11)(lightningcss@1.30.2): + vite@5.4.21(@types/node@20.19.35)(lightningcss@1.31.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.57.1 + optionalDependencies: + '@types/node': 20.19.35 + fsevents: 2.3.3 + lightningcss: 1.31.1 + + vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1): dependencies: esbuild: 0.21.5 postcss: 8.5.6 @@ -4228,12 +4131,12 @@ snapshots: optionalDependencies: '@types/node': 24.10.11 fsevents: 2.3.3 - lightningcss: 1.30.2 + lightningcss: 1.31.1 - vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.30.2): + vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.30.2)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -4249,8 +4152,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.30.2) - vite-node: 2.1.9(@types/node@24.10.11)(lightningcss@1.30.2) + vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) + vite-node: 2.1.9(@types/node@24.10.11)(lightningcss@1.31.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.11 @@ -4312,4 +4215,5 @@ snapshots: yallist@3.1.1: {} - yaml@2.8.2: {} + yaml@2.8.2: + optional: true