@clipkit/editor 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +51 -0
- package/dist/Editor.d.ts +3 -0
- package/dist/Editor.d.ts.map +1 -0
- package/dist/Editor.js +73 -0
- package/dist/Editor.js.map +1 -0
- package/dist/ExportDialog.d.ts +12 -0
- package/dist/ExportDialog.d.ts.map +1 -0
- package/dist/ExportDialog.js +30 -0
- package/dist/ExportDialog.js.map +1 -0
- package/dist/MotionPathOverlay.d.ts +5 -0
- package/dist/MotionPathOverlay.d.ts.map +1 -0
- package/dist/MotionPathOverlay.js +156 -0
- package/dist/MotionPathOverlay.js.map +1 -0
- package/dist/PerfHud.d.ts +2 -0
- package/dist/PerfHud.d.ts.map +1 -0
- package/dist/PerfHud.js +85 -0
- package/dist/PerfHud.js.map +1 -0
- package/dist/Stage.d.ts +2 -0
- package/dist/Stage.d.ts.map +1 -0
- package/dist/Stage.js +406 -0
- package/dist/Stage.js.map +1 -0
- package/dist/StageOverlay.d.ts +7 -0
- package/dist/StageOverlay.d.ts.map +1 -0
- package/dist/StageOverlay.js +508 -0
- package/dist/StageOverlay.js.map +1 -0
- package/dist/commands.d.ts +18 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +103 -0
- package/dist/commands.js.map +1 -0
- package/dist/configuration.d.ts +9 -0
- package/dist/configuration.d.ts.map +1 -0
- package/dist/configuration.js +21 -0
- package/dist/configuration.js.map +1 -0
- package/dist/controls/AnimationsStack.d.ts +8 -0
- package/dist/controls/AnimationsStack.d.ts.map +1 -0
- package/dist/controls/AnimationsStack.js +188 -0
- package/dist/controls/AnimationsStack.js.map +1 -0
- package/dist/controls/CameraControl.d.ts +19 -0
- package/dist/controls/CameraControl.d.ts.map +1 -0
- package/dist/controls/CameraControl.js +47 -0
- package/dist/controls/CameraControl.js.map +1 -0
- package/dist/controls/CaptionLengthControl.d.ts +5 -0
- package/dist/controls/CaptionLengthControl.d.ts.map +1 -0
- package/dist/controls/CaptionLengthControl.js +11 -0
- package/dist/controls/CaptionLengthControl.js.map +1 -0
- package/dist/controls/CaptionTranscribe.d.ts +2 -0
- package/dist/controls/CaptionTranscribe.d.ts.map +1 -0
- package/dist/controls/CaptionTranscribe.js +95 -0
- package/dist/controls/CaptionTranscribe.js.map +1 -0
- package/dist/controls/ColorPicker.d.ts +17 -0
- package/dist/controls/ColorPicker.d.ts.map +1 -0
- package/dist/controls/ColorPicker.js +354 -0
- package/dist/controls/ColorPicker.js.map +1 -0
- package/dist/controls/ControlRenderer.d.ts +20 -0
- package/dist/controls/ControlRenderer.d.ts.map +1 -0
- package/dist/controls/ControlRenderer.js +106 -0
- package/dist/controls/ControlRenderer.js.map +1 -0
- package/dist/controls/CropControl.d.ts +2 -0
- package/dist/controls/CropControl.d.ts.map +1 -0
- package/dist/controls/CropControl.js +177 -0
- package/dist/controls/CropControl.js.map +1 -0
- package/dist/controls/EffectsStack.d.ts +8 -0
- package/dist/controls/EffectsStack.d.ts.map +1 -0
- package/dist/controls/EffectsStack.js +89 -0
- package/dist/controls/EffectsStack.js.map +1 -0
- package/dist/controls/GradeControl.d.ts +2 -0
- package/dist/controls/GradeControl.d.ts.map +1 -0
- package/dist/controls/GradeControl.js +120 -0
- package/dist/controls/GradeControl.js.map +1 -0
- package/dist/controls/KeyframeDiamond.d.ts +11 -0
- package/dist/controls/KeyframeDiamond.d.ts.map +1 -0
- package/dist/controls/KeyframeDiamond.js +87 -0
- package/dist/controls/KeyframeDiamond.js.map +1 -0
- package/dist/controls/LightingControls.d.ts +24 -0
- package/dist/controls/LightingControls.d.ts.map +1 -0
- package/dist/controls/LightingControls.js +108 -0
- package/dist/controls/LightingControls.js.map +1 -0
- package/dist/controls/ShapePresetControl.d.ts +4 -0
- package/dist/controls/ShapePresetControl.d.ts.map +1 -0
- package/dist/controls/ShapePresetControl.js +30 -0
- package/dist/controls/ShapePresetControl.js.map +1 -0
- package/dist/controls/ValueField.d.ts +10 -0
- package/dist/controls/ValueField.d.ts.map +1 -0
- package/dist/controls/ValueField.js +158 -0
- package/dist/controls/ValueField.js.map +1 -0
- package/dist/controls/VolumeControl.d.ts +10 -0
- package/dist/controls/VolumeControl.d.ts.map +1 -0
- package/dist/controls/VolumeControl.js +75 -0
- package/dist/controls/VolumeControl.js.map +1 -0
- package/dist/controls/compound.d.ts +46 -0
- package/dist/controls/compound.d.ts.map +1 -0
- package/dist/controls/compound.js +160 -0
- package/dist/controls/compound.js.map +1 -0
- package/dist/controls/layout.d.ts +38 -0
- package/dist/controls/layout.d.ts.map +1 -0
- package/dist/controls/layout.js +162 -0
- package/dist/controls/layout.js.map +1 -0
- package/dist/controls/primitives.d.ts +83 -0
- package/dist/controls/primitives.d.ts.map +1 -0
- package/dist/controls/primitives.js +194 -0
- package/dist/controls/primitives.js.map +1 -0
- package/dist/controls/transcribe.worker.d.ts +2 -0
- package/dist/controls/transcribe.worker.d.ts.map +1 -0
- package/dist/controls/transcribe.worker.js +22 -0
- package/dist/controls/transcribe.worker.js.map +1 -0
- package/dist/frame/AddElementBar.d.ts +2 -0
- package/dist/frame/AddElementBar.d.ts.map +1 -0
- package/dist/frame/AddElementBar.js +103 -0
- package/dist/frame/AddElementBar.js.map +1 -0
- package/dist/frame/Breadcrumbs.d.ts +2 -0
- package/dist/frame/Breadcrumbs.d.ts.map +1 -0
- package/dist/frame/Breadcrumbs.js +32 -0
- package/dist/frame/Breadcrumbs.js.map +1 -0
- package/dist/frame/GroupFlash.d.ts +2 -0
- package/dist/frame/GroupFlash.d.ts.map +1 -0
- package/dist/frame/GroupFlash.js +65 -0
- package/dist/frame/GroupFlash.js.map +1 -0
- package/dist/frame/Resizable.d.ts +12 -0
- package/dist/frame/Resizable.d.ts.map +1 -0
- package/dist/frame/Resizable.js +37 -0
- package/dist/frame/Resizable.js.map +1 -0
- package/dist/frame/Section.d.ts +23 -0
- package/dist/frame/Section.d.ts.map +1 -0
- package/dist/frame/Section.js +23 -0
- package/dist/frame/Section.js.map +1 -0
- package/dist/frame/ZoomControl.d.ts +9 -0
- package/dist/frame/ZoomControl.d.ts.map +1 -0
- package/dist/frame/ZoomControl.js +15 -0
- package/dist/frame/ZoomControl.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/camera-gizmo.d.ts +15 -0
- package/dist/lib/camera-gizmo.d.ts.map +1 -0
- package/dist/lib/camera-gizmo.js +57 -0
- package/dist/lib/camera-gizmo.js.map +1 -0
- package/dist/lib/camera-tool.d.ts +43 -0
- package/dist/lib/camera-tool.d.ts.map +1 -0
- package/dist/lib/camera-tool.js +80 -0
- package/dist/lib/camera-tool.js.map +1 -0
- package/dist/lib/caption-segments.d.ts +17 -0
- package/dist/lib/caption-segments.d.ts.map +1 -0
- package/dist/lib/caption-segments.js +50 -0
- package/dist/lib/caption-segments.js.map +1 -0
- package/dist/lib/group.d.ts +12 -0
- package/dist/lib/group.d.ts.map +1 -0
- package/dist/lib/group.js +61 -0
- package/dist/lib/group.js.map +1 -0
- package/dist/lib/keyframes.d.ts +29 -0
- package/dist/lib/keyframes.d.ts.map +1 -0
- package/dist/lib/keyframes.js +92 -0
- package/dist/lib/keyframes.js.map +1 -0
- package/dist/lib/sfx-preview.d.ts +18 -0
- package/dist/lib/sfx-preview.d.ts.map +1 -0
- package/dist/lib/sfx-preview.js +74 -0
- package/dist/lib/sfx-preview.js.map +1 -0
- package/dist/lib/shape-presets.d.ts +35 -0
- package/dist/lib/shape-presets.d.ts.map +1 -0
- package/dist/lib/shape-presets.js +81 -0
- package/dist/lib/shape-presets.js.map +1 -0
- package/dist/lib/ungroup.d.ts +12 -0
- package/dist/lib/ungroup.d.ts.map +1 -0
- package/dist/lib/ungroup.js +40 -0
- package/dist/lib/ungroup.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +9 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/panels/AssetsPanel.d.ts +2 -0
- package/dist/panels/AssetsPanel.d.ts.map +1 -0
- package/dist/panels/AssetsPanel.js +108 -0
- package/dist/panels/AssetsPanel.js.map +1 -0
- package/dist/panels/InspectorPanel.d.ts +2 -0
- package/dist/panels/InspectorPanel.d.ts.map +1 -0
- package/dist/panels/InspectorPanel.js +286 -0
- package/dist/panels/InspectorPanel.js.map +1 -0
- package/dist/panels/InterpolationPanel.d.ts +2 -0
- package/dist/panels/InterpolationPanel.d.ts.map +1 -0
- package/dist/panels/InterpolationPanel.js +226 -0
- package/dist/panels/InterpolationPanel.js.map +1 -0
- package/dist/panels/LayersTree.d.ts +4 -0
- package/dist/panels/LayersTree.d.ts.map +1 -0
- package/dist/panels/LayersTree.js +137 -0
- package/dist/panels/LayersTree.js.map +1 -0
- package/dist/panels/LeftRail.d.ts +6 -0
- package/dist/panels/LeftRail.d.ts.map +1 -0
- package/dist/panels/LeftRail.js +35 -0
- package/dist/panels/LeftRail.js.map +1 -0
- package/dist/panels/SourcePanel.d.ts +2 -0
- package/dist/panels/SourcePanel.d.ts.map +1 -0
- package/dist/panels/SourcePanel.js +470 -0
- package/dist/panels/SourcePanel.js.map +1 -0
- package/dist/panels/TimelinePanel.d.ts +11 -0
- package/dist/panels/TimelinePanel.d.ts.map +1 -0
- package/dist/panels/TimelinePanel.js +98 -0
- package/dist/panels/TimelinePanel.js.map +1 -0
- package/dist/panels/assets/SfxBrowser.d.ts +2 -0
- package/dist/panels/assets/SfxBrowser.d.ts.map +1 -0
- package/dist/panels/assets/SfxBrowser.js +49 -0
- package/dist/panels/assets/SfxBrowser.js.map +1 -0
- package/dist/panels/assets/use-assets.d.ts +11 -0
- package/dist/panels/assets/use-assets.d.ts.map +1 -0
- package/dist/panels/assets/use-assets.js +84 -0
- package/dist/panels/assets/use-assets.js.map +1 -0
- package/dist/panels/assets/use-sfx.d.ts +6 -0
- package/dist/panels/assets/use-sfx.d.ts.map +1 -0
- package/dist/panels/assets/use-sfx.js +47 -0
- package/dist/panels/assets/use-sfx.js.map +1 -0
- package/dist/panels/timeline/CanvasTimeline.d.ts +7 -0
- package/dist/panels/timeline/CanvasTimeline.d.ts.map +1 -0
- package/dist/panels/timeline/CanvasTimeline.js +1536 -0
- package/dist/panels/timeline/CanvasTimeline.js.map +1 -0
- package/dist/panels/timeline/Clip.d.ts +37 -0
- package/dist/panels/timeline/Clip.d.ts.map +1 -0
- package/dist/panels/timeline/Clip.js +176 -0
- package/dist/panels/timeline/Clip.js.map +1 -0
- package/dist/panels/timeline/CurveEditor.d.ts +2 -0
- package/dist/panels/timeline/CurveEditor.d.ts.map +1 -0
- package/dist/panels/timeline/CurveEditor.js +233 -0
- package/dist/panels/timeline/CurveEditor.js.map +1 -0
- package/dist/panels/timeline/MixerRail.d.ts +7 -0
- package/dist/panels/timeline/MixerRail.d.ts.map +1 -0
- package/dist/panels/timeline/MixerRail.js +295 -0
- package/dist/panels/timeline/MixerRail.js.map +1 -0
- package/dist/panels/timeline/Waveform.d.ts +11 -0
- package/dist/panels/timeline/Waveform.d.ts.map +1 -0
- package/dist/panels/timeline/Waveform.js +63 -0
- package/dist/panels/timeline/Waveform.js.map +1 -0
- package/dist/panels/timeline/clip-style.d.ts +10 -0
- package/dist/panels/timeline/clip-style.d.ts.map +1 -0
- package/dist/panels/timeline/clip-style.js +20 -0
- package/dist/panels/timeline/clip-style.js.map +1 -0
- package/dist/panels/timeline/filmstrip.d.ts +7 -0
- package/dist/panels/timeline/filmstrip.d.ts.map +1 -0
- package/dist/panels/timeline/filmstrip.js +135 -0
- package/dist/panels/timeline/filmstrip.js.map +1 -0
- package/dist/panels/timeline/timeline-layout.d.ts +65 -0
- package/dist/panels/timeline/timeline-layout.d.ts.map +1 -0
- package/dist/panels/timeline/timeline-layout.js +118 -0
- package/dist/panels/timeline/timeline-layout.js.map +1 -0
- package/dist/types.d.ts +68 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -0
- package/src/styles.css +185 -0
|
@@ -0,0 +1,1536 @@
|
|
|
1
|
+
// Canvas timeline (EDITORS D9, hybrid — ruled by Ian 2026-06-12).
|
|
2
|
+
// A viewport-sized canvas PAINTS the scroll area (rows, ruler, clips,
|
|
3
|
+
// keyframe lanes, snap guide) offset by scroll; a second canvas paints
|
|
4
|
+
// the playhead on rAF; a virtualized DOM overlay places transparent
|
|
5
|
+
// hit-rects over only the on-screen clips/handles/diamonds for
|
|
6
|
+
// interaction + accessibility. Layout comes from timeline-layout.ts;
|
|
7
|
+
// the drag / snap / split math is transplanted from the DOM timeline
|
|
8
|
+
// (it operates on source-time + client-px, medium-independent).
|
|
9
|
+
//
|
|
10
|
+
// Why: the DOM timeline melts on big imports (≈800 layers → thousands
|
|
11
|
+
// of nodes, layout-thrash on zoom/scroll). Here the cost is O(visible
|
|
12
|
+
// rows), not O(project).
|
|
13
|
+
'use client';
|
|
14
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
15
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
16
|
+
import { buildSnapTargets, chooseTickInterval, computeSourceDuration, elementDuration, elementLabel, elementTime, elementLayer, formatTickLabel, resolveGroupPath, snapTo, useEditor, useEditorContext, useEditorStore, } from '@clipkit/editor-core';
|
|
17
|
+
import { extractWaveformPeaks } from '@clipkit/playback';
|
|
18
|
+
import { chunkCaptionWords } from '@clipkit/runtime';
|
|
19
|
+
import { Lock, LockOpen } from 'lucide-react';
|
|
20
|
+
import { cn } from '../../lib/utils.js';
|
|
21
|
+
import { ungroupInElements } from '../../lib/ungroup.js';
|
|
22
|
+
import { groupElements } from '../../lib/group.js';
|
|
23
|
+
import { filmstripFrame } from './filmstrip.js';
|
|
24
|
+
import { KF_EPS, kfTime, toggleKeyframeAt } from '../../lib/keyframes.js';
|
|
25
|
+
import { PALETTE, FALLBACK_SWATCHES } from './Clip.js';
|
|
26
|
+
import { buildLayout, visibleLayerRange, HEADER_W, ROW_H, RULER_H, } from './timeline-layout.js';
|
|
27
|
+
const MIN_DUR = 0.1;
|
|
28
|
+
const SNAP_PX = 6;
|
|
29
|
+
const HANDLE_W = 7;
|
|
30
|
+
// Drag auto-scroll: px band from a viewport edge that starts scrolling, and the
|
|
31
|
+
// per-frame speed cap (px). Speed ramps with how far the cursor is into the band.
|
|
32
|
+
const AUTOSCROLL_EDGE = 36;
|
|
33
|
+
const AUTOSCROLL_MAX = 18;
|
|
34
|
+
// Volume rubber-band ceiling. Like pro editors, unity (100% = 0 dB,
|
|
35
|
+
// the source level) is NOT the max — we allow boost above it (the
|
|
36
|
+
// runtime applies gain = volume/100 with no upper clamp). 200% ≈
|
|
37
|
+
// +6 dB. Unity sits mid-clip, leaving headroom above to boost.
|
|
38
|
+
const VOL_MAX = 200;
|
|
39
|
+
function readChrome(el) {
|
|
40
|
+
const s = getComputedStyle(el);
|
|
41
|
+
const v = (n, fallback) => s.getPropertyValue(n).trim() || fallback;
|
|
42
|
+
return {
|
|
43
|
+
bg: v('--color-background', '#141414'),
|
|
44
|
+
panel: v('--color-card', '#181818'),
|
|
45
|
+
border: v('--color-border', '#232323'),
|
|
46
|
+
borderFaint: v('--color-border', '#232323'),
|
|
47
|
+
text: v('--color-foreground', '#fafafa'),
|
|
48
|
+
muted: v('--color-muted-foreground', '#8a8a8a'),
|
|
49
|
+
playhead: v('--color-playhead', '#5c9be0'),
|
|
50
|
+
secondary: v('--color-secondary', '#161616'),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function CanvasTimeline({ pxPerSec, scrollRef, onScale, }) {
|
|
54
|
+
const { engine, theme, store } = useEditorContext();
|
|
55
|
+
const actions = useEditor();
|
|
56
|
+
const source = useEditorStore((s) => s.source);
|
|
57
|
+
const selection = useEditorStore((s) => s.selection);
|
|
58
|
+
const [locked, setLocked] = useState(new Set());
|
|
59
|
+
const [expanded, setExpanded] = useState(new Set());
|
|
60
|
+
const [snapGuide, setSnapGuide] = useState(null);
|
|
61
|
+
// Live vertical-drag preview: a clip floats freely up/down (dyPx); the slot it
|
|
62
|
+
// would drop into is shown as an insertion gap (see insertGap), committed only
|
|
63
|
+
// on release.
|
|
64
|
+
const [dragGhost, setDragGhost] = useState(null);
|
|
65
|
+
const [menu, setMenu] = useState(null);
|
|
66
|
+
// Marquee (rubber-band) box-select over the clip area. The ORIGIN is stored in
|
|
67
|
+
// content space (cx0/cy0 = absolute timeline px) and the live corner in viewport
|
|
68
|
+
// space (vx/vy = container-relative px). Recomputing the box as `origin - scroll`
|
|
69
|
+
// each render keeps it anchored to the content as the timeline scrolls.
|
|
70
|
+
const [marquee, setMarquee] = useState(null);
|
|
71
|
+
const [scroll, setScroll] = useState({ x: 0, y: 0 });
|
|
72
|
+
// Live scroll for drag handlers (window listeners read the latest, not a stale closure).
|
|
73
|
+
const scrollPosRef = useRef(scroll);
|
|
74
|
+
scrollPosRef.current = scroll;
|
|
75
|
+
const [viewport, setViewport] = useState({ w: 0, h: 0 });
|
|
76
|
+
const [hoveredId, setHoveredId] = useState(null);
|
|
77
|
+
const [peaksTick, setPeaksTick] = useState(0);
|
|
78
|
+
const peaksRef = useRef(new Map());
|
|
79
|
+
const peaksRequested = useRef(new Set());
|
|
80
|
+
// Group drill-down: when inside a group, the timeline lays out that group's
|
|
81
|
+
// children (their times are local to the group). Edits still route through the
|
|
82
|
+
// real source via id (updateElement/moveElements recurse into groups).
|
|
83
|
+
const groupPath = useEditorStore((s) => s.ui.groupPath);
|
|
84
|
+
const { elements: scopedElements, crumbs, timeOffset: groupOffset } = resolveGroupPath(source.elements, groupPath);
|
|
85
|
+
const activeGroup = crumbs.at(-1);
|
|
86
|
+
// Absolute start of the entered group in real comp time — children's local
|
|
87
|
+
// times sit at `groupOffset + childTime`. Playhead/seek/playback map through it.
|
|
88
|
+
const scopedSource = useMemo(() => ({ ...source, elements: scopedElements, duration: activeGroup ? (activeGroup.duration ?? 'auto') : source.duration }), [source, scopedElements, activeGroup]);
|
|
89
|
+
const duration = computeSourceDuration(scopedSource);
|
|
90
|
+
// Live values for the rAF playhead loop + seek helper.
|
|
91
|
+
const groupOffsetRef = useRef(groupOffset);
|
|
92
|
+
groupOffsetRef.current = groupOffset;
|
|
93
|
+
const groupDurRef = useRef(duration);
|
|
94
|
+
groupDurRef.current = duration;
|
|
95
|
+
const inGroup = groupPath.length > 0;
|
|
96
|
+
const inGroupRef = useRef(inGroup);
|
|
97
|
+
inGroupRef.current = inGroup;
|
|
98
|
+
// Seek by LOCAL time (maps to real comp time when inside a group).
|
|
99
|
+
const seekLocal = (localT) => actions.seek(groupOffsetRef.current + Math.max(0, Math.min(groupDurRef.current, localT)));
|
|
100
|
+
const layout = useMemo(() => buildLayout(scopedSource, duration, pxPerSec, expanded), [scopedSource, duration, pxPerSec, expanded]);
|
|
101
|
+
// Enter a group → scope the timeline to it, flash it, and move the playhead to
|
|
102
|
+
// the group's start (so playback is constrained to the group's window).
|
|
103
|
+
const enterGroup = (g) => {
|
|
104
|
+
if (g.type !== 'group' || typeof g.id !== 'string')
|
|
105
|
+
return;
|
|
106
|
+
const childStart = groupOffset + (typeof g.time === 'number' ? g.time : 0);
|
|
107
|
+
actions.setUiState({ groupPath: [...groupPath, g.id], groupFlashId: g.id });
|
|
108
|
+
actions.setSelection([]);
|
|
109
|
+
actions.seek(childStart);
|
|
110
|
+
};
|
|
111
|
+
const scrollElRef = useRef(null);
|
|
112
|
+
const baseCanvasRef = useRef(null);
|
|
113
|
+
const playheadCanvasRef = useRef(null);
|
|
114
|
+
const dragRef = useRef(null);
|
|
115
|
+
const justDraggedRef = useRef(false);
|
|
116
|
+
// Auto-scroll-while-dragging: last cursor (client px) + the rAF handle.
|
|
117
|
+
const lastPointerRef = useRef({ x: 0, y: 0 });
|
|
118
|
+
const autoScrollRef = useRef(null);
|
|
119
|
+
const chromeRef = useRef(null);
|
|
120
|
+
const sourceRef = useRef(source);
|
|
121
|
+
sourceRef.current = source;
|
|
122
|
+
const latest = () => sourceRef.current;
|
|
123
|
+
const selSet = useMemo(() => new Set(selection), [selection]);
|
|
124
|
+
// ── Insertion gap: while dragging a single clip vertically, an empty lane
|
|
125
|
+
// opens at the boundary the cursor's midpoint rule selects (above a row's
|
|
126
|
+
// vertical midpoint → slot above it; below → slot below it). `atY` is the
|
|
127
|
+
// content-y of the boundary (rows at/below shift down by `height`); `toLayer`
|
|
128
|
+
// is the reflow target committed on release. The gap snaps between boundaries
|
|
129
|
+
// as the cursor crosses midpoints and eases open/closed. ──
|
|
130
|
+
const [insertGap, setInsertGap] = useState(null);
|
|
131
|
+
const gapRef = useRef(null);
|
|
132
|
+
const gapRafRef = useRef(null);
|
|
133
|
+
const runGapAnim = () => {
|
|
134
|
+
if (gapRafRef.current != null)
|
|
135
|
+
return;
|
|
136
|
+
const tick = () => {
|
|
137
|
+
const g = gapRef.current;
|
|
138
|
+
if (!g) {
|
|
139
|
+
gapRafRef.current = null;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
g.height += (g.target - g.height) * 0.3;
|
|
143
|
+
const settled = Math.abs(g.target - g.height) < 0.5;
|
|
144
|
+
if (settled)
|
|
145
|
+
g.height = g.target;
|
|
146
|
+
if (settled && g.target === 0) {
|
|
147
|
+
gapRef.current = null;
|
|
148
|
+
gapRafRef.current = null;
|
|
149
|
+
setInsertGap(null);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
setInsertGap({ atY: g.atY, height: g.height });
|
|
153
|
+
gapRafRef.current = settled ? null : requestAnimationFrame(tick);
|
|
154
|
+
};
|
|
155
|
+
gapRafRef.current = requestAnimationFrame(tick);
|
|
156
|
+
};
|
|
157
|
+
// Open (or relocate) the gap at a boundary. atY/toLayer update immediately so
|
|
158
|
+
// the gap snaps to the new slot; height eases toward a full row.
|
|
159
|
+
const openGap = (atY, toLayer) => {
|
|
160
|
+
const cur = gapRef.current;
|
|
161
|
+
gapRef.current = { atY, toLayer, height: cur?.height ?? 0, target: ROW_H };
|
|
162
|
+
setInsertGap({ atY, height: gapRef.current.height });
|
|
163
|
+
runGapAnim();
|
|
164
|
+
};
|
|
165
|
+
const closeGap = () => {
|
|
166
|
+
if (gapRef.current) {
|
|
167
|
+
gapRef.current.target = 0;
|
|
168
|
+
runGapAnim();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
// Cancel any pending rAF on unmount.
|
|
172
|
+
useEffect(() => () => {
|
|
173
|
+
if (gapRafRef.current != null)
|
|
174
|
+
cancelAnimationFrame(gapRafRef.current);
|
|
175
|
+
if (autoScrollRef.current != null)
|
|
176
|
+
cancelAnimationFrame(autoScrollRef.current);
|
|
177
|
+
}, []);
|
|
178
|
+
// ── Viewport measurement ────────────────────────────────────────────
|
|
179
|
+
const setScrollEl = (el) => {
|
|
180
|
+
scrollElRef.current = el;
|
|
181
|
+
if (typeof scrollRef === 'function')
|
|
182
|
+
scrollRef(el);
|
|
183
|
+
else if (scrollRef)
|
|
184
|
+
scrollRef.current = el;
|
|
185
|
+
};
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const el = scrollElRef.current;
|
|
188
|
+
if (!el)
|
|
189
|
+
return;
|
|
190
|
+
const measure = () => setViewport({ w: el.clientWidth, h: el.clientHeight });
|
|
191
|
+
measure();
|
|
192
|
+
chromeRef.current = readChrome(el);
|
|
193
|
+
const ro = new ResizeObserver(measure);
|
|
194
|
+
ro.observe(el);
|
|
195
|
+
return () => ro.disconnect();
|
|
196
|
+
}, []);
|
|
197
|
+
// Re-read chrome on theme change.
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (scrollElRef.current)
|
|
200
|
+
chromeRef.current = readChrome(scrollElRef.current);
|
|
201
|
+
drawBase();
|
|
202
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
203
|
+
}, [theme]);
|
|
204
|
+
// ── Ctrl/⌘ + wheel (and trackpad pinch) zoom, cursor-anchored ──────
|
|
205
|
+
// Plain wheel still scrolls the layers; shift+wheel scrolls time
|
|
206
|
+
// (both native). The zoom keeps the time under the cursor fixed by
|
|
207
|
+
// adjusting scrollLeft after the new scale applies (layout effect).
|
|
208
|
+
const pendingZoom = useRef(null);
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const el = scrollElRef.current;
|
|
211
|
+
if (!el || !onScale)
|
|
212
|
+
return;
|
|
213
|
+
const onWheel = (e) => {
|
|
214
|
+
if (!e.ctrlKey && !e.metaKey)
|
|
215
|
+
return; // plain wheel = scroll
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
const px = e.clientX - el.getBoundingClientRect().left - HEADER_W;
|
|
218
|
+
if (px < 0)
|
|
219
|
+
return;
|
|
220
|
+
pendingZoom.current = { time: (el.scrollLeft + px) / pxPerSec, px };
|
|
221
|
+
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
|
222
|
+
onScale(pxPerSec * factor);
|
|
223
|
+
};
|
|
224
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
225
|
+
return () => el.removeEventListener('wheel', onWheel);
|
|
226
|
+
}, [onScale, pxPerSec]);
|
|
227
|
+
// After a wheel-zoom rescales pxPerSec, pin the anchored time back
|
|
228
|
+
// under the cursor.
|
|
229
|
+
useLayoutEffect(() => {
|
|
230
|
+
const pz = pendingZoom.current;
|
|
231
|
+
const el = scrollElRef.current;
|
|
232
|
+
if (!pz || !el)
|
|
233
|
+
return;
|
|
234
|
+
pendingZoom.current = null;
|
|
235
|
+
el.scrollLeft = Math.max(0, pz.time * pxPerSec - pz.px);
|
|
236
|
+
}, [pxPerSec]);
|
|
237
|
+
// ── Base canvas paint ────────────────────────────────────────────────
|
|
238
|
+
const drawBase = () => {
|
|
239
|
+
const canvas = baseCanvasRef.current;
|
|
240
|
+
const chrome = chromeRef.current;
|
|
241
|
+
if (!canvas || !chrome || viewport.w === 0)
|
|
242
|
+
return;
|
|
243
|
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
|
244
|
+
if (canvas.width !== viewport.w * dpr || canvas.height !== viewport.h * dpr) {
|
|
245
|
+
canvas.width = viewport.w * dpr;
|
|
246
|
+
canvas.height = viewport.h * dpr;
|
|
247
|
+
}
|
|
248
|
+
const ctx = canvas.getContext('2d');
|
|
249
|
+
if (!ctx)
|
|
250
|
+
return;
|
|
251
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
252
|
+
ctx.clearRect(0, 0, viewport.w, viewport.h);
|
|
253
|
+
ctx.font =
|
|
254
|
+
'10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
|
|
255
|
+
ctx.textBaseline = 'middle';
|
|
256
|
+
const sx = (cx) => HEADER_W + cx - scroll.x;
|
|
257
|
+
// syRaw = unshifted; sy adds the insertion gap so rows at/below the boundary
|
|
258
|
+
// make room. The dragged clip + its ghost use syRaw (they float above the
|
|
259
|
+
// reflow), everything else uses sy.
|
|
260
|
+
const syRaw = (cy) => RULER_H + cy - scroll.y;
|
|
261
|
+
const gapY = insertGap?.atY ?? Infinity;
|
|
262
|
+
const gapH = insertGap?.height ?? 0;
|
|
263
|
+
const sy = (cy) => syRaw(cy) + (cy >= gapY ? gapH : 0);
|
|
264
|
+
const { start, end } = visibleLayerRange(layout.layers, scroll.y, viewport.h - RULER_H);
|
|
265
|
+
// ── Content region (clipped so nothing paints under header/ruler) ──
|
|
266
|
+
ctx.save();
|
|
267
|
+
ctx.beginPath();
|
|
268
|
+
ctx.rect(HEADER_W, RULER_H, viewport.w - HEADER_W, viewport.h - RULER_H);
|
|
269
|
+
ctx.clip();
|
|
270
|
+
for (let i = start; i < end; i++) {
|
|
271
|
+
const row = layout.layers[i];
|
|
272
|
+
const rowTop = sy(row.y);
|
|
273
|
+
// Row separator — a soft mid-gray hairline. The old faint border
|
|
274
|
+
// (dark gray @ 0.4) blended into the near-black bg; muted-foreground
|
|
275
|
+
// reads clearly on both the dark and light timeline backgrounds.
|
|
276
|
+
ctx.strokeStyle = chrome.muted;
|
|
277
|
+
ctx.globalAlpha = 0.3;
|
|
278
|
+
ctx.beginPath();
|
|
279
|
+
ctx.moveTo(HEADER_W, rowTop + row.h - 0.5);
|
|
280
|
+
ctx.lineTo(viewport.w, rowTop + row.h - 0.5);
|
|
281
|
+
ctx.stroke();
|
|
282
|
+
ctx.globalAlpha = 1;
|
|
283
|
+
}
|
|
284
|
+
// Clips (only those on visible rows + horizontally in view).
|
|
285
|
+
const visLayerNums = new Set(layout.layers.slice(start, end).map((l) => l.layer));
|
|
286
|
+
for (const clip of layout.clips) {
|
|
287
|
+
if (!visLayerNums.has(clip.layer))
|
|
288
|
+
continue;
|
|
289
|
+
const x = sx(clip.x);
|
|
290
|
+
if (x + clip.w < HEADER_W || x > viewport.w)
|
|
291
|
+
continue;
|
|
292
|
+
if (dragGhost?.ids.has(clip.id))
|
|
293
|
+
continue; // dragged → drawn in the float pass below
|
|
294
|
+
const url = getMediaUrl(clip.element);
|
|
295
|
+
// Audio → waveform; video → filmstrip (frames drawn in drawClip
|
|
296
|
+
// via the getFrame callback).
|
|
297
|
+
const peaks = clip.element.type === 'audio' && url ? peaksRef.current.get(url) ?? null : null;
|
|
298
|
+
drawClip(ctx, clip, x, sy(clip.y), selSet.has(clip.id), clip.id === hoveredId, theme, locked.has(clip.layer), peaks, pxPerSec, bumpMedia);
|
|
299
|
+
}
|
|
300
|
+
// Keyframe lanes.
|
|
301
|
+
for (const lane of layout.lanes) {
|
|
302
|
+
const laneY = sy(lane.y);
|
|
303
|
+
if (laneY + lane.h < RULER_H || laneY > viewport.h)
|
|
304
|
+
continue;
|
|
305
|
+
ctx.fillStyle = chrome.secondary;
|
|
306
|
+
ctx.globalAlpha = 0.5;
|
|
307
|
+
ctx.fillRect(HEADER_W, laneY, viewport.w - HEADER_W, lane.h);
|
|
308
|
+
ctx.globalAlpha = 1;
|
|
309
|
+
for (const kf of lane.keyframes) {
|
|
310
|
+
const kx = sx(kf.x);
|
|
311
|
+
if (kx < HEADER_W - 6 || kx > viewport.w + 6)
|
|
312
|
+
continue;
|
|
313
|
+
drawDiamond(ctx, kx, sy(kf.y), 4, chrome.playhead);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Snap guide.
|
|
317
|
+
if (snapGuide !== null) {
|
|
318
|
+
const gx = sx(snapGuide * pxPerSec);
|
|
319
|
+
if (gx >= HEADER_W) {
|
|
320
|
+
ctx.strokeStyle = chrome.playhead;
|
|
321
|
+
ctx.globalAlpha = 0.8;
|
|
322
|
+
ctx.beginPath();
|
|
323
|
+
ctx.moveTo(gx, RULER_H);
|
|
324
|
+
ctx.lineTo(gx, viewport.h);
|
|
325
|
+
ctx.stroke();
|
|
326
|
+
ctx.globalAlpha = 1;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// New-layer gap: an empty highlighted lane in the space the rows opened up.
|
|
330
|
+
if (insertGap && insertGap.height > 0.5) {
|
|
331
|
+
const gy = syRaw(insertGap.atY);
|
|
332
|
+
const k = Math.min(1, insertGap.height / ROW_H);
|
|
333
|
+
ctx.fillStyle = chrome.playhead;
|
|
334
|
+
ctx.globalAlpha = 0.12 * k;
|
|
335
|
+
ctx.fillRect(HEADER_W, gy, viewport.w - HEADER_W, insertGap.height);
|
|
336
|
+
ctx.globalAlpha = 0.6 * k;
|
|
337
|
+
ctx.strokeStyle = chrome.playhead;
|
|
338
|
+
ctx.setLineDash([5, 4]);
|
|
339
|
+
ctx.lineWidth = 1;
|
|
340
|
+
ctx.beginPath();
|
|
341
|
+
ctx.moveTo(HEADER_W, gy + 0.5);
|
|
342
|
+
ctx.lineTo(viewport.w, gy + 0.5);
|
|
343
|
+
ctx.moveTo(HEADER_W, gy + insertGap.height - 0.5);
|
|
344
|
+
ctx.lineTo(viewport.w, gy + insertGap.height - 0.5);
|
|
345
|
+
ctx.stroke();
|
|
346
|
+
ctx.setLineDash([]);
|
|
347
|
+
ctx.globalAlpha = 1;
|
|
348
|
+
}
|
|
349
|
+
// Dragged clip(s): a stripped ghost PINNED at the original position (it does
|
|
350
|
+
// not move), plus the full-fidelity clip floating at the live cursor
|
|
351
|
+
// position. Both use syRaw → they float ABOVE the gap reflow, no jump.
|
|
352
|
+
if (dragGhost) {
|
|
353
|
+
for (const clip of layout.clips) {
|
|
354
|
+
if (!dragGhost.ids.has(clip.id))
|
|
355
|
+
continue;
|
|
356
|
+
const rowY = syRaw(clip.y); // layer is unchanged mid-drag → origin lane
|
|
357
|
+
// Ghost, pinned to where the clip started.
|
|
358
|
+
drawClip(ctx, clip, sx(dragGhost.originX[clip.id] ?? clip.x), rowY, false, false, theme, false, null, pxPerSec, bumpMedia, true);
|
|
359
|
+
// Full clip, floating at the cursor.
|
|
360
|
+
const url = getMediaUrl(clip.element);
|
|
361
|
+
const peaks = clip.element.type === 'audio' && url ? peaksRef.current.get(url) ?? null : null;
|
|
362
|
+
drawClip(ctx, clip, sx(clip.x), rowY + dragGhost.dyPx, true, false, theme, false, peaks, pxPerSec, bumpMedia, false);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
ctx.restore();
|
|
366
|
+
// ── Ruler (top strip, opaque over content) ──
|
|
367
|
+
ctx.fillStyle = chrome.bg;
|
|
368
|
+
ctx.fillRect(HEADER_W, 0, viewport.w - HEADER_W, RULER_H);
|
|
369
|
+
ctx.strokeStyle = chrome.border;
|
|
370
|
+
ctx.beginPath();
|
|
371
|
+
ctx.moveTo(HEADER_W, RULER_H - 0.5);
|
|
372
|
+
ctx.lineTo(viewport.w, RULER_H - 0.5);
|
|
373
|
+
ctx.stroke();
|
|
374
|
+
const { major, minor } = chooseTickInterval(pxPerSec);
|
|
375
|
+
const lastTick = Math.max(0, Math.ceil(duration));
|
|
376
|
+
const tickCount = Math.max(0, Math.ceil(lastTick / minor - 1e-6));
|
|
377
|
+
ctx.save();
|
|
378
|
+
ctx.beginPath();
|
|
379
|
+
ctx.rect(HEADER_W, 0, viewport.w - HEADER_W, RULER_H);
|
|
380
|
+
ctx.clip();
|
|
381
|
+
for (let i = 0; i < tickCount; i++) {
|
|
382
|
+
const t = i * minor;
|
|
383
|
+
const x = sx(t * pxPerSec);
|
|
384
|
+
if (x < HEADER_W - 1 || x > viewport.w)
|
|
385
|
+
continue;
|
|
386
|
+
const isMajor = i % 5 === 0;
|
|
387
|
+
// muted-foreground reads on the dark ruler; border was too dark.
|
|
388
|
+
ctx.strokeStyle = chrome.muted;
|
|
389
|
+
ctx.globalAlpha = isMajor ? 0.7 : 0.4;
|
|
390
|
+
ctx.beginPath();
|
|
391
|
+
ctx.moveTo(x, isMajor ? 0 : RULER_H - 6);
|
|
392
|
+
ctx.lineTo(x, RULER_H);
|
|
393
|
+
ctx.stroke();
|
|
394
|
+
ctx.globalAlpha = 1;
|
|
395
|
+
if (isMajor) {
|
|
396
|
+
ctx.fillStyle = chrome.muted;
|
|
397
|
+
ctx.fillText(formatTickLabel(t, duration, major), x + 4, RULER_H / 2);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
ctx.restore();
|
|
401
|
+
// ── Post-end zone: a diagonal-stripe band past where the source ends
|
|
402
|
+
// (the "video is over here" affordance, matching the old ruler).
|
|
403
|
+
// The end is rounded up to a whole second like the ticks. ──
|
|
404
|
+
const endX = sx(lastTick * pxPerSec);
|
|
405
|
+
const bandStart = Math.max(endX, HEADER_W);
|
|
406
|
+
if (bandStart < viewport.w) {
|
|
407
|
+
ctx.save();
|
|
408
|
+
ctx.beginPath();
|
|
409
|
+
ctx.rect(bandStart, 0, viewport.w - bandStart, RULER_H);
|
|
410
|
+
ctx.clip();
|
|
411
|
+
// 45° hatch, ~4px stripe / ~4px gap (lineWidth 4, x-step ≈ 8/sin45°).
|
|
412
|
+
ctx.strokeStyle = chrome.border;
|
|
413
|
+
ctx.globalAlpha = 0.6;
|
|
414
|
+
ctx.lineWidth = 4;
|
|
415
|
+
ctx.beginPath();
|
|
416
|
+
for (let x = bandStart - RULER_H; x < viewport.w; x += 11) {
|
|
417
|
+
ctx.moveTo(x, RULER_H);
|
|
418
|
+
ctx.lineTo(x + RULER_H, 0);
|
|
419
|
+
}
|
|
420
|
+
ctx.stroke();
|
|
421
|
+
ctx.restore();
|
|
422
|
+
// Crisp end-line marking where the content actually stops.
|
|
423
|
+
if (endX >= HEADER_W && endX <= viewport.w) {
|
|
424
|
+
ctx.strokeStyle = chrome.border;
|
|
425
|
+
ctx.beginPath();
|
|
426
|
+
ctx.moveTo(endX + 0.5, 0);
|
|
427
|
+
ctx.lineTo(endX + 0.5, RULER_H);
|
|
428
|
+
ctx.stroke();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// ── Header column (left strip, opaque) ──
|
|
432
|
+
ctx.fillStyle = chrome.bg;
|
|
433
|
+
ctx.fillRect(0, 0, HEADER_W, viewport.h);
|
|
434
|
+
ctx.strokeStyle = chrome.border;
|
|
435
|
+
ctx.beginPath();
|
|
436
|
+
ctx.moveTo(HEADER_W - 0.5, 0);
|
|
437
|
+
ctx.lineTo(HEADER_W - 0.5, viewport.h);
|
|
438
|
+
ctx.stroke();
|
|
439
|
+
ctx.save();
|
|
440
|
+
ctx.beginPath();
|
|
441
|
+
ctx.rect(0, RULER_H, HEADER_W, viewport.h - RULER_H);
|
|
442
|
+
ctx.clip();
|
|
443
|
+
for (let i = start; i < end; i++) {
|
|
444
|
+
const row = layout.layers[i];
|
|
445
|
+
const rowTop = sy(row.y);
|
|
446
|
+
ctx.fillStyle = chrome.muted;
|
|
447
|
+
const labelX = row.animId ? 30 : 22;
|
|
448
|
+
ctx.fillText(`Layer ${row.layer}`, labelX, rowTop + ROW_H / 2);
|
|
449
|
+
}
|
|
450
|
+
ctx.restore();
|
|
451
|
+
// Corner.
|
|
452
|
+
ctx.fillStyle = chrome.bg;
|
|
453
|
+
ctx.fillRect(0, 0, HEADER_W, RULER_H);
|
|
454
|
+
ctx.strokeStyle = chrome.border;
|
|
455
|
+
ctx.beginPath();
|
|
456
|
+
ctx.moveTo(0, RULER_H - 0.5);
|
|
457
|
+
ctx.lineTo(HEADER_W, RULER_H - 0.5);
|
|
458
|
+
ctx.moveTo(HEADER_W - 0.5, 0);
|
|
459
|
+
ctx.lineTo(HEADER_W - 0.5, RULER_H);
|
|
460
|
+
ctx.stroke();
|
|
461
|
+
};
|
|
462
|
+
useEffect(drawBase, [layout, scroll, viewport, selSet, locked, snapGuide, theme, peaksTick, hoveredId, dragGhost, insertGap]);
|
|
463
|
+
// Redraw when async media (waveforms / filmstrip frames) lands.
|
|
464
|
+
const bumpMedia = () => setPeaksTick((t) => t + 1);
|
|
465
|
+
// Lazily decode waveform peaks for visible AUDIO clips. Cached per
|
|
466
|
+
// URL, so revisits are cheap.
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
const { start, end } = visibleLayerRange(layout.layers, scroll.y, viewport.h - RULER_H);
|
|
469
|
+
const visNums = new Set(layout.layers.slice(start, end).map((l) => l.layer));
|
|
470
|
+
for (const clip of layout.clips) {
|
|
471
|
+
if (!visNums.has(clip.layer) || clip.element.type !== 'audio')
|
|
472
|
+
continue;
|
|
473
|
+
const url = getMediaUrl(clip.element);
|
|
474
|
+
if (!url || peaksRequested.current.has(url))
|
|
475
|
+
continue;
|
|
476
|
+
peaksRequested.current.add(url);
|
|
477
|
+
extractWaveformPeaks(url)
|
|
478
|
+
.then((p) => {
|
|
479
|
+
peaksRef.current.set(url, p);
|
|
480
|
+
setPeaksTick((t) => t + 1);
|
|
481
|
+
})
|
|
482
|
+
.catch(() => { });
|
|
483
|
+
}
|
|
484
|
+
}, [layout, scroll.y, viewport.h]);
|
|
485
|
+
// ── Playhead canvas (rAF) ────────────────────────────────────────────
|
|
486
|
+
useEffect(() => {
|
|
487
|
+
const canvas = playheadCanvasRef.current;
|
|
488
|
+
if (!canvas || viewport.w === 0)
|
|
489
|
+
return;
|
|
490
|
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
|
491
|
+
canvas.width = viewport.w * dpr;
|
|
492
|
+
canvas.height = viewport.h * dpr;
|
|
493
|
+
const ctx = canvas.getContext('2d');
|
|
494
|
+
if (!ctx)
|
|
495
|
+
return;
|
|
496
|
+
let raf = 0;
|
|
497
|
+
let last = Number.NaN;
|
|
498
|
+
const paint = () => {
|
|
499
|
+
let t = engine?.currentTime ?? 0;
|
|
500
|
+
// Constrain playback to the entered group's window — don't run past it.
|
|
501
|
+
if (inGroupRef.current && engine?.playing) {
|
|
502
|
+
const start = groupOffsetRef.current;
|
|
503
|
+
const end = start + groupDurRef.current;
|
|
504
|
+
if (t >= end - 1e-3) {
|
|
505
|
+
const loop = store.getState().ui.loop;
|
|
506
|
+
engine.seek(loop ? start : end);
|
|
507
|
+
if (!loop)
|
|
508
|
+
engine.pause();
|
|
509
|
+
t = engine.currentTime;
|
|
510
|
+
}
|
|
511
|
+
else if (t < start) {
|
|
512
|
+
engine.seek(start);
|
|
513
|
+
t = engine.currentTime;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const localT = inGroupRef.current ? t - groupOffsetRef.current : t;
|
|
517
|
+
if (localT !== last) {
|
|
518
|
+
last = localT;
|
|
519
|
+
const chrome = chromeRef.current;
|
|
520
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
521
|
+
ctx.clearRect(0, 0, viewport.w, viewport.h);
|
|
522
|
+
const x = HEADER_W + localT * pxPerSec - scroll.x;
|
|
523
|
+
if (x >= HEADER_W && x <= viewport.w && chrome) {
|
|
524
|
+
ctx.strokeStyle = 'var(--color-destructive)';
|
|
525
|
+
// destructive token doesn't resolve in canvas; use a literal red.
|
|
526
|
+
ctx.strokeStyle = '#ef4444';
|
|
527
|
+
ctx.fillStyle = '#ef4444';
|
|
528
|
+
ctx.beginPath();
|
|
529
|
+
ctx.moveTo(x, 0);
|
|
530
|
+
ctx.lineTo(x, viewport.h);
|
|
531
|
+
ctx.stroke();
|
|
532
|
+
ctx.beginPath();
|
|
533
|
+
ctx.moveTo(x - 9, 0);
|
|
534
|
+
ctx.lineTo(x + 9, 0);
|
|
535
|
+
ctx.lineTo(x, 14);
|
|
536
|
+
ctx.closePath();
|
|
537
|
+
ctx.fill();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
raf = requestAnimationFrame(paint);
|
|
541
|
+
};
|
|
542
|
+
raf = requestAnimationFrame(paint);
|
|
543
|
+
return () => cancelAnimationFrame(raf);
|
|
544
|
+
}, [engine, pxPerSec, scroll, viewport]);
|
|
545
|
+
// ── Context-menu outside-close ──────────────────────────────────────
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (!menu)
|
|
548
|
+
return;
|
|
549
|
+
const close = () => setMenu(null);
|
|
550
|
+
window.addEventListener('mousedown', close);
|
|
551
|
+
return () => window.removeEventListener('mousedown', close);
|
|
552
|
+
}, [menu]);
|
|
553
|
+
// ── Drag machinery (transplanted) ───────────────────────────────────
|
|
554
|
+
const beginDrag = (e, el, mode, extra) => {
|
|
555
|
+
if (e.button !== 0 || !el.id || locked.has(elementLayer(el)))
|
|
556
|
+
return;
|
|
557
|
+
e.preventDefault();
|
|
558
|
+
e.stopPropagation();
|
|
559
|
+
const id = el.id;
|
|
560
|
+
if (e.shiftKey && mode === 'move') {
|
|
561
|
+
const next = selection.includes(id)
|
|
562
|
+
? selection.filter((s) => s !== id)
|
|
563
|
+
: [...selection, id];
|
|
564
|
+
actions.setSelection(next);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const movingIds = mode === 'move' && selection.includes(id) && selection.length > 1
|
|
568
|
+
? selection
|
|
569
|
+
: [id];
|
|
570
|
+
if (!selection.includes(id))
|
|
571
|
+
actions.selectOne(id);
|
|
572
|
+
const st0 = latest();
|
|
573
|
+
const moving = movingIds
|
|
574
|
+
.map((mid) => findById(st0.elements, mid))
|
|
575
|
+
.filter((m) => !!m && !!m.id)
|
|
576
|
+
.map((m) => ({
|
|
577
|
+
id: m.id,
|
|
578
|
+
time: elementTime(m),
|
|
579
|
+
layer: elementLayer(m),
|
|
580
|
+
dur: elementDuration(m, duration),
|
|
581
|
+
}));
|
|
582
|
+
dragRef.current = {
|
|
583
|
+
mode,
|
|
584
|
+
id,
|
|
585
|
+
startX: e.clientX,
|
|
586
|
+
startY: e.clientY,
|
|
587
|
+
origTime: elementTime(el),
|
|
588
|
+
origDur: elementDuration(el, duration),
|
|
589
|
+
origLayer: elementLayer(el),
|
|
590
|
+
started: false,
|
|
591
|
+
moving,
|
|
592
|
+
...extra,
|
|
593
|
+
};
|
|
594
|
+
// The drag application, parameterized by pointer position so the auto-scroll
|
|
595
|
+
// loop can re-run it at the last cursor while the view scrolls underneath.
|
|
596
|
+
const applyDrag = (clientX, clientY) => {
|
|
597
|
+
const d = dragRef.current;
|
|
598
|
+
if (!d)
|
|
599
|
+
return;
|
|
600
|
+
const dx = clientX - d.startX;
|
|
601
|
+
const dy = clientY - d.startY;
|
|
602
|
+
if (!d.started && Math.abs(dx) < 3 && Math.abs(dy) < 3)
|
|
603
|
+
return;
|
|
604
|
+
if (!d.started) {
|
|
605
|
+
d.started = true;
|
|
606
|
+
actions.pushHistory();
|
|
607
|
+
actions.setInteractive(true);
|
|
608
|
+
startAutoScroll();
|
|
609
|
+
}
|
|
610
|
+
const dt = dx / pxPerSec;
|
|
611
|
+
const st = latest();
|
|
612
|
+
const movingSet = new Set(d.moving.map((m) => m.id));
|
|
613
|
+
const targets = buildSnapTargets(st, duration, movingSet, engine?.currentTime ?? 0);
|
|
614
|
+
const thresholdSec = SNAP_PX / pxPerSec;
|
|
615
|
+
if (d.mode === 'move') {
|
|
616
|
+
const s1 = snapTo(d.origTime + dt, targets, thresholdSec);
|
|
617
|
+
const s2 = snapTo(d.origTime + d.origDur + dt, targets, thresholdSec);
|
|
618
|
+
let snappedDt = dt;
|
|
619
|
+
let guide = null;
|
|
620
|
+
if (s2.target !== null) {
|
|
621
|
+
snappedDt = s2.value - (d.origTime + d.origDur);
|
|
622
|
+
guide = s2.target;
|
|
623
|
+
}
|
|
624
|
+
if (s1.target !== null) {
|
|
625
|
+
snappedDt = s1.value - d.origTime;
|
|
626
|
+
guide = s1.target;
|
|
627
|
+
}
|
|
628
|
+
const minTime = Math.min(...d.moving.map((m) => m.time));
|
|
629
|
+
snappedDt = Math.max(-minTime, snappedDt);
|
|
630
|
+
setSnapGuide(guide);
|
|
631
|
+
// Horizontal commits live (time + snapping, unchanged). The lane stays
|
|
632
|
+
// put mid-drag so the clip never jumps layers; the vertical is a FREE
|
|
633
|
+
// ghost that only settles into a layer on release (see onUp) — so up/down
|
|
634
|
+
// floats with the cursor instead of snapping lane-to-lane.
|
|
635
|
+
// Clipkit layers are compositing layers, not exclusive NLE lanes —
|
|
636
|
+
// elements are meant to share the screen, so time-overlap on a layer is
|
|
637
|
+
// legal. No collision block (that's what made dragging feel "stuck").
|
|
638
|
+
const updates = d.moving.map((m) => ({
|
|
639
|
+
id: m.id,
|
|
640
|
+
patch: { time: round3(m.time + snappedDt) },
|
|
641
|
+
}));
|
|
642
|
+
actions.moveElements(updates, { skipHistory: true });
|
|
643
|
+
// Single-clip vertical reorder: float the clip and open an insertion gap
|
|
644
|
+
// at the boundary the cursor's midpoint rule selects. (Multi-select keeps
|
|
645
|
+
// its lanes — no vertical reorder.)
|
|
646
|
+
const sEl = scrollElRef.current;
|
|
647
|
+
const rows = layout.layers;
|
|
648
|
+
if (d.moving.length === 1 && Math.abs(dy) > 4) {
|
|
649
|
+
// Original content-x per clip (drag-start times) — pins the ghost.
|
|
650
|
+
const originX = {};
|
|
651
|
+
for (const m of d.moving)
|
|
652
|
+
originX[m.id] = m.time * pxPerSec;
|
|
653
|
+
setDragGhost({ ids: movingSet, dyPx: dy, originX });
|
|
654
|
+
// Insertion slot p ∈ [0, N] = number of rows whose vertical midpoint
|
|
655
|
+
// sits above the cursor (p=0 → above the top row, p=N → below the last).
|
|
656
|
+
let to;
|
|
657
|
+
let atY = 0;
|
|
658
|
+
if (sEl && rows.length >= 1) {
|
|
659
|
+
const cy = clientY - sEl.getBoundingClientRect().top - RULER_H + sEl.scrollTop;
|
|
660
|
+
let p = 0;
|
|
661
|
+
for (const r of rows)
|
|
662
|
+
if (cy >= r.y + r.h / 2)
|
|
663
|
+
p++;
|
|
664
|
+
const origPos = rows.findIndex((r) => r.layer === d.origLayer);
|
|
665
|
+
// Reflow target: the row just below the gap when moving toward the
|
|
666
|
+
// front, just above it when moving toward the back. The two slots
|
|
667
|
+
// flanking the clip's own row resolve to origLayer → a no-op (no gap).
|
|
668
|
+
const cand = p <= origPos ? rows[p]?.layer : rows[p - 1]?.layer;
|
|
669
|
+
if (cand !== undefined && cand !== d.origLayer && !locked.has(cand)) {
|
|
670
|
+
to = cand;
|
|
671
|
+
atY = p < rows.length ? rows[p].y : layout.contentH;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (to !== undefined)
|
|
675
|
+
openGap(atY, to);
|
|
676
|
+
else if (gapRef.current)
|
|
677
|
+
closeGap();
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
setDragGhost(null);
|
|
681
|
+
if (gapRef.current)
|
|
682
|
+
closeGap();
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
else if (d.mode === 'trim-l') {
|
|
686
|
+
const endT = d.origTime + d.origDur;
|
|
687
|
+
const s = snapTo(d.origTime + dt, targets, thresholdSec);
|
|
688
|
+
setSnapGuide(s.target);
|
|
689
|
+
const t = Math.max(0, Math.min(endT - MIN_DUR, s.value));
|
|
690
|
+
actions.moveElements([{ id: d.id, patch: { time: round3(t), duration: round3(endT - t) } }], { skipHistory: true });
|
|
691
|
+
}
|
|
692
|
+
else if (d.mode === 'trim-r') {
|
|
693
|
+
const s = snapTo(d.origTime + d.origDur + dt, targets, thresholdSec);
|
|
694
|
+
setSnapGuide(s.target);
|
|
695
|
+
const endT = Math.max(d.origTime + MIN_DUR, s.value);
|
|
696
|
+
actions.moveElements([{ id: d.id, patch: { duration: round3(endT - d.origTime) } }], { skipHistory: true });
|
|
697
|
+
}
|
|
698
|
+
else if (d.mode === 'keyframe' && d.animIndex !== undefined && d.kfIndex !== undefined) {
|
|
699
|
+
const el2 = findById(st.elements, d.id);
|
|
700
|
+
const anims = el2?.keyframe_animations;
|
|
701
|
+
if (!el2 || !anims)
|
|
702
|
+
return;
|
|
703
|
+
const nextT = Math.max(0, (d.origKfTime ?? 0) + dt);
|
|
704
|
+
const nextAnims = anims.map((a, ai) => ai !== d.animIndex
|
|
705
|
+
? a
|
|
706
|
+
: {
|
|
707
|
+
...a,
|
|
708
|
+
keyframes: a.keyframes.map((k, ki) => ki === d.kfIndex ? { ...k, time: round3(nextT) } : k),
|
|
709
|
+
});
|
|
710
|
+
actions.moveElements([{ id: d.id, patch: { keyframe_animations: nextAnims } }], { skipHistory: true });
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
// Edge auto-scroll: while a drag is active and the cursor sits in a band near
|
|
714
|
+
// a viewport edge, scroll that way each frame and fold the delta into the drag
|
|
715
|
+
// origin (startX/startY) so the floating clip stays under the cursor and the
|
|
716
|
+
// committed time / drop slot follow the newly revealed content. Vertical only
|
|
717
|
+
// applies to 'move' (reorder); horizontal applies to every mode.
|
|
718
|
+
const startAutoScroll = () => {
|
|
719
|
+
if (autoScrollRef.current != null)
|
|
720
|
+
return;
|
|
721
|
+
const spd = (pen) => Math.min(AUTOSCROLL_MAX, 2 + pen * 0.3);
|
|
722
|
+
const tick = () => {
|
|
723
|
+
const d = dragRef.current;
|
|
724
|
+
const sEl = scrollElRef.current;
|
|
725
|
+
if (!d || !d.started || !sEl) {
|
|
726
|
+
autoScrollRef.current = null;
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const lp = lastPointerRef.current;
|
|
730
|
+
const r = sEl.getBoundingClientRect();
|
|
731
|
+
let vy = 0;
|
|
732
|
+
if (d.mode === 'move') {
|
|
733
|
+
const topZ = r.top + RULER_H + AUTOSCROLL_EDGE;
|
|
734
|
+
const botZ = r.bottom - AUTOSCROLL_EDGE;
|
|
735
|
+
if (lp.y < topZ)
|
|
736
|
+
vy = -spd(topZ - lp.y);
|
|
737
|
+
else if (lp.y > botZ)
|
|
738
|
+
vy = spd(lp.y - botZ);
|
|
739
|
+
}
|
|
740
|
+
let vx = 0;
|
|
741
|
+
const leftZ = r.left + HEADER_W + AUTOSCROLL_EDGE;
|
|
742
|
+
const rightZ = r.right - AUTOSCROLL_EDGE;
|
|
743
|
+
if (lp.x < leftZ)
|
|
744
|
+
vx = -spd(leftZ - lp.x);
|
|
745
|
+
else if (lp.x > rightZ)
|
|
746
|
+
vx = spd(lp.x - rightZ);
|
|
747
|
+
if (vy !== 0 || vx !== 0) {
|
|
748
|
+
const beforeTop = sEl.scrollTop;
|
|
749
|
+
const beforeLeft = sEl.scrollLeft;
|
|
750
|
+
const nextTop = Math.max(0, Math.min(sEl.scrollHeight - sEl.clientHeight, beforeTop + vy));
|
|
751
|
+
const nextLeft = Math.max(0, Math.min(sEl.scrollWidth - sEl.clientWidth, beforeLeft + vx));
|
|
752
|
+
const ady = nextTop - beforeTop;
|
|
753
|
+
const adx = nextLeft - beforeLeft;
|
|
754
|
+
if (ady !== 0 || adx !== 0) {
|
|
755
|
+
sEl.scrollTop = nextTop;
|
|
756
|
+
sEl.scrollLeft = nextLeft;
|
|
757
|
+
d.startY -= ady;
|
|
758
|
+
d.startX -= adx;
|
|
759
|
+
applyDrag(lp.x, lp.y);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
autoScrollRef.current = requestAnimationFrame(tick);
|
|
763
|
+
};
|
|
764
|
+
autoScrollRef.current = requestAnimationFrame(tick);
|
|
765
|
+
};
|
|
766
|
+
const stopAutoScroll = () => {
|
|
767
|
+
if (autoScrollRef.current != null) {
|
|
768
|
+
cancelAnimationFrame(autoScrollRef.current);
|
|
769
|
+
autoScrollRef.current = null;
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
const onMove = (ev) => {
|
|
773
|
+
lastPointerRef.current = { x: ev.clientX, y: ev.clientY };
|
|
774
|
+
applyDrag(ev.clientX, ev.clientY);
|
|
775
|
+
};
|
|
776
|
+
const onUp = () => {
|
|
777
|
+
const d = dragRef.current;
|
|
778
|
+
dragRef.current = null;
|
|
779
|
+
justDraggedRef.current = d?.started === true;
|
|
780
|
+
window.removeEventListener('mousemove', onMove);
|
|
781
|
+
window.removeEventListener('mouseup', onUp);
|
|
782
|
+
stopAutoScroll();
|
|
783
|
+
setSnapGuide(null);
|
|
784
|
+
setDragGhost(null);
|
|
785
|
+
const gap = gapRef.current;
|
|
786
|
+
gapRef.current = null;
|
|
787
|
+
if (gapRafRef.current != null) {
|
|
788
|
+
cancelAnimationFrame(gapRafRef.current);
|
|
789
|
+
gapRafRef.current = null;
|
|
790
|
+
}
|
|
791
|
+
setInsertGap(null);
|
|
792
|
+
if (d?.started) {
|
|
793
|
+
// Vertical drag = reorder layers. The dragged element takes the slot the
|
|
794
|
+
// insertion gap marks and every element it crossed shifts by one — so the
|
|
795
|
+
// container stays uniquely + densely layered (layer 1 = front). No
|
|
796
|
+
// collision, no "new lane" creation.
|
|
797
|
+
if (d.mode === 'move') {
|
|
798
|
+
const to = gap && gap.target > 0 ? gap.toLayer : undefined;
|
|
799
|
+
if (to !== undefined && to !== d.origLayer) {
|
|
800
|
+
const from = d.origLayer;
|
|
801
|
+
const ups = [];
|
|
802
|
+
for (const el of latest().elements) {
|
|
803
|
+
if (!el.id || el.id === d.id || typeof el.layer !== 'number')
|
|
804
|
+
continue;
|
|
805
|
+
const l = el.layer;
|
|
806
|
+
if (to < from) {
|
|
807
|
+
if (l >= to && l < from)
|
|
808
|
+
ups.push({ id: el.id, patch: { layer: l + 1 } });
|
|
809
|
+
}
|
|
810
|
+
else if (l > from && l <= to) {
|
|
811
|
+
ups.push({ id: el.id, patch: { layer: l - 1 } });
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
ups.push({ id: d.id, patch: { layer: to } });
|
|
815
|
+
actions.moveElements(ups, { skipHistory: true });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (d.mode === 'keyframe' && d.animIndex !== undefined) {
|
|
819
|
+
const st = latest();
|
|
820
|
+
const el2 = findById(st.elements, d.id);
|
|
821
|
+
const anims = el2?.keyframe_animations;
|
|
822
|
+
if (el2 && anims) {
|
|
823
|
+
const nextAnims = anims.map((a, ai) => ai !== d.animIndex ? a : { ...a, keyframes: [...a.keyframes].sort(byKfTime) });
|
|
824
|
+
actions.moveElements([{ id: d.id, patch: { keyframe_animations: nextAnims } }], { skipHistory: true });
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
actions.flushPendingSource();
|
|
828
|
+
actions.setInteractive(false);
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
window.addEventListener('mousemove', onMove);
|
|
832
|
+
window.addEventListener('mouseup', onUp);
|
|
833
|
+
};
|
|
834
|
+
// Fade-handle drag — writes audio_fade_in / audio_fade_out (literal
|
|
835
|
+
// seconds), one undo step per gesture. Dragging the in-dot right (or
|
|
836
|
+
// the out-dot left) lengthens the fade; clamped to [0, clip dur].
|
|
837
|
+
const beginFadeDrag = (e, el, side) => {
|
|
838
|
+
if (e.button !== 0 || !el.id || locked.has(elementLayer(el)))
|
|
839
|
+
return;
|
|
840
|
+
e.preventDefault();
|
|
841
|
+
e.stopPropagation();
|
|
842
|
+
const id = el.id;
|
|
843
|
+
if (!selection.includes(id))
|
|
844
|
+
actions.selectOne(id);
|
|
845
|
+
const dur = elementDuration(el, duration);
|
|
846
|
+
const field = side === 'in' ? 'audio_fade_in' : 'audio_fade_out';
|
|
847
|
+
const orig = num(el[field]);
|
|
848
|
+
const startX = e.clientX;
|
|
849
|
+
let started = false;
|
|
850
|
+
const onMove = (ev) => {
|
|
851
|
+
const dx = ev.clientX - startX;
|
|
852
|
+
if (!started && Math.abs(dx) < 3)
|
|
853
|
+
return;
|
|
854
|
+
if (!started) {
|
|
855
|
+
started = true;
|
|
856
|
+
actions.pushHistory();
|
|
857
|
+
actions.setInteractive(true);
|
|
858
|
+
}
|
|
859
|
+
const dSec = (side === 'in' ? dx : -dx) / pxPerSec;
|
|
860
|
+
const next = Math.max(0, Math.min(dur, round3(orig + dSec)));
|
|
861
|
+
actions.moveElements([{ id, patch: { [field]: next } }], { skipHistory: true });
|
|
862
|
+
};
|
|
863
|
+
const onUp = () => {
|
|
864
|
+
window.removeEventListener('mousemove', onMove);
|
|
865
|
+
window.removeEventListener('mouseup', onUp);
|
|
866
|
+
justDraggedRef.current = started;
|
|
867
|
+
if (started) {
|
|
868
|
+
actions.flushPendingSource();
|
|
869
|
+
actions.setInteractive(false);
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
window.addEventListener('mousemove', onMove);
|
|
873
|
+
window.addEventListener('mouseup', onUp);
|
|
874
|
+
};
|
|
875
|
+
// Volume rubber-band drag — vertical, writes the static `volume`
|
|
876
|
+
// (percent), one undo step. A full clip-height drag spans 0..100.
|
|
877
|
+
const beginVolumeDrag = (e, el, clipH) => {
|
|
878
|
+
if (e.button !== 0 || !el.id || locked.has(elementLayer(el)))
|
|
879
|
+
return;
|
|
880
|
+
if (Array.isArray(el.volume))
|
|
881
|
+
return; // animated → inspector
|
|
882
|
+
e.preventDefault();
|
|
883
|
+
e.stopPropagation();
|
|
884
|
+
const id = el.id;
|
|
885
|
+
if (!selection.includes(id))
|
|
886
|
+
actions.selectOne(id);
|
|
887
|
+
const orig = typeof el.volume === 'number'
|
|
888
|
+
? el.volume
|
|
889
|
+
: 100;
|
|
890
|
+
const startY = e.clientY;
|
|
891
|
+
let started = false;
|
|
892
|
+
const onMove = (ev) => {
|
|
893
|
+
const dy = ev.clientY - startY;
|
|
894
|
+
if (!started && Math.abs(dy) < 3)
|
|
895
|
+
return;
|
|
896
|
+
if (!started) {
|
|
897
|
+
started = true;
|
|
898
|
+
actions.pushHistory();
|
|
899
|
+
actions.setInteractive(true);
|
|
900
|
+
}
|
|
901
|
+
const next = Math.max(0, Math.min(VOL_MAX, Math.round(orig - (dy / clipH) * VOL_MAX)));
|
|
902
|
+
actions.moveElements([{ id, patch: { volume: next } }], { skipHistory: true });
|
|
903
|
+
};
|
|
904
|
+
const onUp = () => {
|
|
905
|
+
window.removeEventListener('mousemove', onMove);
|
|
906
|
+
window.removeEventListener('mouseup', onUp);
|
|
907
|
+
justDraggedRef.current = started;
|
|
908
|
+
if (started) {
|
|
909
|
+
actions.flushPendingSource();
|
|
910
|
+
actions.setInteractive(false);
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
window.addEventListener('mousemove', onMove);
|
|
914
|
+
window.addEventListener('mouseup', onUp);
|
|
915
|
+
};
|
|
916
|
+
const menuActions = (id) => {
|
|
917
|
+
const st = latest();
|
|
918
|
+
const el = findById(st.elements, id);
|
|
919
|
+
const playhead = engine?.currentTime ?? 0;
|
|
920
|
+
const canSplit = !!el &&
|
|
921
|
+
!el.time_remap &&
|
|
922
|
+
playhead > elementTime(el) + 0.05 &&
|
|
923
|
+
playhead < elementTime(el) + elementDuration(el, duration) - 0.05;
|
|
924
|
+
return {
|
|
925
|
+
canSplit,
|
|
926
|
+
split: () => {
|
|
927
|
+
if (!el || !canSplit)
|
|
928
|
+
return;
|
|
929
|
+
const parts = splitElement(el, playhead, duration);
|
|
930
|
+
if (!parts)
|
|
931
|
+
return;
|
|
932
|
+
const next = [];
|
|
933
|
+
for (const e2 of st.elements) {
|
|
934
|
+
if (e2 === el)
|
|
935
|
+
next.push(parts[0], parts[1]);
|
|
936
|
+
else
|
|
937
|
+
next.push(e2);
|
|
938
|
+
}
|
|
939
|
+
actions.patchSource({ elements: next });
|
|
940
|
+
actions.selectOne(parts[1].id);
|
|
941
|
+
},
|
|
942
|
+
duplicate: () => {
|
|
943
|
+
if (!el)
|
|
944
|
+
return;
|
|
945
|
+
const start = elementTime(el);
|
|
946
|
+
const dur = elementDuration(el, duration);
|
|
947
|
+
const copy = {
|
|
948
|
+
...el,
|
|
949
|
+
id: `${el.id}-copy-${Date.now().toString(36).slice(-4)}`,
|
|
950
|
+
time: round3(start + dur),
|
|
951
|
+
};
|
|
952
|
+
actions.addElement(copy);
|
|
953
|
+
},
|
|
954
|
+
remove: () => actions.removeElement(id),
|
|
955
|
+
isGroup: el?.type === 'group',
|
|
956
|
+
enter: () => { if (el)
|
|
957
|
+
enterGroup(el); },
|
|
958
|
+
ungroup: () => {
|
|
959
|
+
if (!el || el.type !== 'group' || typeof el.id !== 'string')
|
|
960
|
+
return;
|
|
961
|
+
const r = ungroupInElements(st.elements, el.id);
|
|
962
|
+
if (!r)
|
|
963
|
+
return;
|
|
964
|
+
actions.patchSource({ elements: r.elements });
|
|
965
|
+
actions.setSelection(r.liftedIds);
|
|
966
|
+
},
|
|
967
|
+
canGroup: selection.length >= 2 && typeof el?.id === 'string' && selection.includes(el.id),
|
|
968
|
+
group: () => {
|
|
969
|
+
const r = groupElements(st.elements, selection, `group-${Date.now().toString(36).slice(-5)}`);
|
|
970
|
+
if (!r)
|
|
971
|
+
return;
|
|
972
|
+
actions.patchSource({ elements: r.elements });
|
|
973
|
+
actions.selectOne(r.groupId);
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
};
|
|
977
|
+
// ── DOM overlay positions ───────────────────────────────────────────
|
|
978
|
+
const sx = (cx) => HEADER_W + cx - scroll.x;
|
|
979
|
+
const sy = (cy) => RULER_H + cy - scroll.y;
|
|
980
|
+
// Marquee box-select: drag a rectangle over empty clip area to select every
|
|
981
|
+
// intersecting clip. Coords are relative to the content-clip container (so
|
|
982
|
+
// they match clip rects, which are at clip.x - scroll.x / clip.y - scroll.y).
|
|
983
|
+
const beginMarquee = (e) => {
|
|
984
|
+
if (e.button !== 0)
|
|
985
|
+
return;
|
|
986
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
987
|
+
const additive = e.shiftKey || e.metaKey || e.ctrlKey;
|
|
988
|
+
const base = additive ? selection : [];
|
|
989
|
+
const vx0 = e.clientX - rect.left;
|
|
990
|
+
const vy0 = e.clientY - rect.top;
|
|
991
|
+
// Origin in CONTENT space so it stays put as the timeline scrolls under it.
|
|
992
|
+
const s0 = scrollPosRef.current;
|
|
993
|
+
const cx0 = vx0 + s0.x;
|
|
994
|
+
const cy0 = vy0 + s0.y;
|
|
995
|
+
setMarquee({ cx0, cy0, vx: vx0, vy: vy0 });
|
|
996
|
+
let moved = false;
|
|
997
|
+
const onMove = (ev) => {
|
|
998
|
+
const vx = ev.clientX - rect.left;
|
|
999
|
+
const vy = ev.clientY - rect.top;
|
|
1000
|
+
const s = scrollPosRef.current;
|
|
1001
|
+
if (Math.abs(vx + s.x - cx0) > 3 || Math.abs(vy + s.y - cy0) > 3)
|
|
1002
|
+
moved = true;
|
|
1003
|
+
setMarquee({ cx0, cy0, vx, vy });
|
|
1004
|
+
};
|
|
1005
|
+
const onUp = (ev) => {
|
|
1006
|
+
window.removeEventListener('mousemove', onMove);
|
|
1007
|
+
window.removeEventListener('mouseup', onUp);
|
|
1008
|
+
setMarquee(null);
|
|
1009
|
+
if (!moved) {
|
|
1010
|
+
if (!additive)
|
|
1011
|
+
actions.setSelection([]); // plain click on empty = clear
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const s = scrollPosRef.current;
|
|
1015
|
+
// Both corners in content space → hit-test is scroll-independent.
|
|
1016
|
+
const cx1 = ev.clientX - rect.left + s.x;
|
|
1017
|
+
const cy1 = ev.clientY - rect.top + s.y;
|
|
1018
|
+
const ml = Math.min(cx0, cx1), mr = Math.max(cx0, cx1), mt = Math.min(cy0, cy1), mb = Math.max(cy0, cy1);
|
|
1019
|
+
const hits = layout.clips
|
|
1020
|
+
.filter((c) => c.x < mr && c.x + c.w > ml && c.y < mb && c.y + c.h > mt)
|
|
1021
|
+
.map((c) => c.id);
|
|
1022
|
+
actions.setSelection(additive ? [...new Set([...base, ...hits])] : hits);
|
|
1023
|
+
};
|
|
1024
|
+
window.addEventListener('mousemove', onMove);
|
|
1025
|
+
window.addEventListener('mouseup', onUp);
|
|
1026
|
+
};
|
|
1027
|
+
const { start, end } = visibleLayerRange(layout.layers, scroll.y, viewport.h - RULER_H);
|
|
1028
|
+
const visLayers = layout.layers.slice(start, end);
|
|
1029
|
+
const visLayerNums = new Set(visLayers.map((l) => l.layer));
|
|
1030
|
+
const visClips = layout.clips.filter((c) => {
|
|
1031
|
+
if (!visLayerNums.has(c.layer))
|
|
1032
|
+
return false;
|
|
1033
|
+
const x = sx(c.x);
|
|
1034
|
+
return x + c.w >= HEADER_W && x <= viewport.w;
|
|
1035
|
+
});
|
|
1036
|
+
const visLanes = layout.lanes.filter((l) => {
|
|
1037
|
+
const y = sy(l.y);
|
|
1038
|
+
return y + l.h >= RULER_H && y <= viewport.h;
|
|
1039
|
+
});
|
|
1040
|
+
return (_jsxs("div", { ref: setScrollEl, className: "flex-1 min-h-0 overflow-auto relative", onScroll: (e) => setScroll({ x: e.currentTarget.scrollLeft, y: e.currentTarget.scrollTop }), children: [_jsx("div", { style: {
|
|
1041
|
+
width: HEADER_W + layout.contentW,
|
|
1042
|
+
height: RULER_H + layout.contentH,
|
|
1043
|
+
}, children: _jsxs("div", { className: "sticky top-0 left-0 w-0 h-0 z-0", children: [_jsx("canvas", { ref: baseCanvasRef, className: "absolute top-0 left-0 pointer-events-none", style: { width: viewport.w, height: viewport.h } }), _jsx("canvas", { ref: playheadCanvasRef, className: "absolute top-0 left-0 pointer-events-none", style: { width: viewport.w, height: viewport.h } }), _jsxs("div", { className: "absolute top-0 left-0 pointer-events-none", style: { width: viewport.w, height: viewport.h }, children: [_jsx("div", { className: "absolute pointer-events-auto cursor-pointer", "aria-label": "Seek timeline", style: { left: HEADER_W, top: 0, width: Math.max(0, viewport.w - HEADER_W), height: RULER_H }, onMouseDown: (e) => {
|
|
1044
|
+
const t = (e.clientX - e.currentTarget.getBoundingClientRect().left + scroll.x) / pxPerSec;
|
|
1045
|
+
seekLocal(t);
|
|
1046
|
+
} }), _jsxs("div", { className: "absolute overflow-hidden pointer-events-none", style: { left: 0, top: RULER_H, width: HEADER_W, height: Math.max(0, viewport.h - RULER_H) }, children: [visLayers.map((row) => {
|
|
1047
|
+
const top = row.y - scroll.y;
|
|
1048
|
+
return (_jsxs("div", { children: [row.animId && (_jsx("button", { type: "button", className: "absolute pointer-events-auto grid place-items-center text-muted-foreground hover:text-foreground", style: { left: 6, top: top + ROW_H / 2 - 8, width: 16, height: 16 }, title: row.expandedId ? 'Collapse keyframe tracks' : 'Expand keyframe tracks', "aria-label": row.expandedId ? 'Collapse keyframe tracks' : 'Expand keyframe tracks', "aria-expanded": !!row.expandedId, onClick: () => setExpanded((prev) => {
|
|
1049
|
+
const n = new Set(prev);
|
|
1050
|
+
const id = row.expandedId ?? row.animId;
|
|
1051
|
+
if (n.has(id))
|
|
1052
|
+
n.delete(id);
|
|
1053
|
+
else
|
|
1054
|
+
n.add(id);
|
|
1055
|
+
return n;
|
|
1056
|
+
}), children: _jsx("svg", { width: "6", height: "6", viewBox: "0 0 8 8", "aria-hidden": "true", className: cn('transition-transform', row.expandedId && 'rotate-90'), children: _jsx("path", { d: "M2 1 L6 4 L2 7 Z", fill: "currentColor" }) }) })), _jsx("button", { type: "button", className: cn('absolute pointer-events-auto grid place-items-center', locked.has(row.layer) ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'), style: { left: HEADER_W - 22, top: top + ROW_H / 2 - 8, width: 16, height: 16 }, title: locked.has(row.layer) ? 'Unlock layer' : 'Lock layer', "aria-label": locked.has(row.layer) ? 'Unlock layer' : 'Lock layer', "aria-pressed": locked.has(row.layer), onClick: () => setLocked((prev) => {
|
|
1057
|
+
const n = new Set(prev);
|
|
1058
|
+
if (n.has(row.layer))
|
|
1059
|
+
n.delete(row.layer);
|
|
1060
|
+
else
|
|
1061
|
+
n.add(row.layer);
|
|
1062
|
+
return n;
|
|
1063
|
+
}), children: locked.has(row.layer) ? _jsx(Lock, { size: 11 }) : _jsx(LockOpen, { size: 11 }) })] }, `h-${row.layer}`));
|
|
1064
|
+
}), visLanes.map((lane) => (_jsx(LaneNav, { elementId: lane.elementId, property: lane.property, animIndex: lane.animIndex, top: lane.y - scroll.y, height: lane.h }, `ln-${lane.elementId}-${lane.property}`)))] }), _jsxs("div", { className: "absolute overflow-hidden pointer-events-none", style: {
|
|
1065
|
+
left: HEADER_W,
|
|
1066
|
+
top: RULER_H,
|
|
1067
|
+
width: Math.max(0, viewport.w - HEADER_W),
|
|
1068
|
+
height: Math.max(0, viewport.h - RULER_H),
|
|
1069
|
+
}, children: [_jsx("div", { className: "absolute inset-0 pointer-events-auto", onMouseDown: beginMarquee }), marquee && (() => {
|
|
1070
|
+
// Origin back into viewport space at the CURRENT scroll, so the box
|
|
1071
|
+
// grows/shrinks correctly while the timeline scrolls.
|
|
1072
|
+
const ox = marquee.cx0 - scroll.x;
|
|
1073
|
+
const oy = marquee.cy0 - scroll.y;
|
|
1074
|
+
return (_jsx("div", { className: "absolute pointer-events-none border border-primary bg-primary/10", style: {
|
|
1075
|
+
left: Math.min(ox, marquee.vx),
|
|
1076
|
+
top: Math.min(oy, marquee.vy),
|
|
1077
|
+
width: Math.abs(marquee.vx - ox),
|
|
1078
|
+
height: Math.abs(marquee.vy - oy),
|
|
1079
|
+
zIndex: 50,
|
|
1080
|
+
} }));
|
|
1081
|
+
})(), visClips.map((clip) => {
|
|
1082
|
+
const left = clip.x - scroll.x;
|
|
1083
|
+
const top = clip.y - scroll.y;
|
|
1084
|
+
const isLocked = locked.has(clip.layer);
|
|
1085
|
+
return (_jsx("div", { className: "absolute pointer-events-auto", style: {
|
|
1086
|
+
left,
|
|
1087
|
+
top,
|
|
1088
|
+
width: clip.w,
|
|
1089
|
+
height: clip.h,
|
|
1090
|
+
cursor: isLocked ? 'not-allowed' : 'grab',
|
|
1091
|
+
}, onMouseDown: (e) => beginDrag(e, clip.element, 'move'), onDoubleClick: (e) => {
|
|
1092
|
+
if (clip.element.type === "group") {
|
|
1093
|
+
e.stopPropagation();
|
|
1094
|
+
enterGroup(clip.element);
|
|
1095
|
+
}
|
|
1096
|
+
}, onMouseEnter: () => setHoveredId(clip.id), onMouseLeave: () => setHoveredId((h) => (h === clip.id ? null : h)), onContextMenu: (e) => {
|
|
1097
|
+
e.preventDefault();
|
|
1098
|
+
if (!selection.includes(clip.id))
|
|
1099
|
+
actions.selectOne(clip.id);
|
|
1100
|
+
setMenu({ x: e.clientX, y: e.clientY, id: clip.id });
|
|
1101
|
+
}, title: elementLabel(clip.element), "aria-label": elementLabel(clip.element), children: !isLocked && clip.w > HANDLE_W * 2 + 8 && (_jsxs(_Fragment, { children: [_jsx("div", { className: "absolute top-0 bottom-0 left-0 cursor-ew-resize", style: { width: HANDLE_W }, onMouseDown: (e) => beginDrag(e, clip.element, 'trim-l') }), _jsx("div", { className: "absolute top-0 bottom-0 right-0 cursor-ew-resize", style: { width: HANDLE_W }, onMouseDown: (e) => beginDrag(e, clip.element, 'trim-r') })] })) }, clip.id));
|
|
1102
|
+
}), visClips.flatMap((clip) => {
|
|
1103
|
+
if (clip.element.type !== 'audio')
|
|
1104
|
+
return [];
|
|
1105
|
+
if (locked.has(clip.layer))
|
|
1106
|
+
return [];
|
|
1107
|
+
const vol = clip.element.volume;
|
|
1108
|
+
if (Array.isArray(vol))
|
|
1109
|
+
return [];
|
|
1110
|
+
const v = typeof vol === 'number' ? vol : 100;
|
|
1111
|
+
const ly = (clip.y - scroll.y) + (1 - Math.min(1, Math.max(0, v / VOL_MAX))) * clip.h;
|
|
1112
|
+
const left = clip.x - scroll.x;
|
|
1113
|
+
return [
|
|
1114
|
+
_jsx("div", { className: "absolute pointer-events-auto cursor-ns-resize", style: { left: left + 12, top: ly - 6, width: Math.max(0, clip.w - 24), height: 13 }, title: `Volume ${v}% — drag up/down`, "aria-label": "Volume line", onMouseDown: (ev) => beginVolumeDrag(ev, clip.element, clip.h), onMouseEnter: () => setHoveredId(clip.id) }, `vol-${clip.id}`),
|
|
1115
|
+
];
|
|
1116
|
+
}), visClips.flatMap((clip) => {
|
|
1117
|
+
if (clip.element.type !== 'audio')
|
|
1118
|
+
return [];
|
|
1119
|
+
if (locked.has(clip.layer))
|
|
1120
|
+
return [];
|
|
1121
|
+
const el = clip.element;
|
|
1122
|
+
const fin = num(el.audio_fade_in);
|
|
1123
|
+
const fout = num(el.audio_fade_out);
|
|
1124
|
+
const top = clip.y - scroll.y;
|
|
1125
|
+
const inX = clip.x + fin * pxPerSec - scroll.x;
|
|
1126
|
+
const outX = clip.x + clip.w - fout * pxPerSec - scroll.x;
|
|
1127
|
+
const dot = (key, cx, side) => (_jsx("div", { className: "absolute pointer-events-auto cursor-grab", style: { left: cx - 7, top: top - 3, width: 14, height: 14 }, title: `Fade ${side === 'in' ? 'in' : 'out'} — drag to adjust`, "aria-label": `Fade ${side} handle`, onMouseDown: (ev) => beginFadeDrag(ev, clip.element, side), onMouseEnter: () => setHoveredId(clip.id) }, key));
|
|
1128
|
+
return [
|
|
1129
|
+
dot(`fi-${clip.id}`, inX, 'in'),
|
|
1130
|
+
dot(`fo-${clip.id}`, outX, 'out'),
|
|
1131
|
+
];
|
|
1132
|
+
}), visLanes.flatMap((lane) => lane.keyframes.map((kf) => {
|
|
1133
|
+
const left = kf.x - scroll.x;
|
|
1134
|
+
if (left < -6 || left > viewport.w - HEADER_W + 6)
|
|
1135
|
+
return null;
|
|
1136
|
+
const el = findById(layout.clips.map((c) => c.element), lane.elementId);
|
|
1137
|
+
return (_jsx("div", { className: "absolute pointer-events-auto cursor-ew-resize", style: { left: left - 6, top: (kf.y - scroll.y) - 6, width: 12, height: 12 }, title: "Drag to retime \u00B7 click to edit interpolation", "aria-label": `Keyframe ${lane.property} @ ${kf.time}s`, onMouseDown: (e) => {
|
|
1138
|
+
if (!el)
|
|
1139
|
+
return;
|
|
1140
|
+
beginDrag(e, el, 'keyframe', {
|
|
1141
|
+
animIndex: kf.animIndex,
|
|
1142
|
+
kfIndex: kf.kfIndex,
|
|
1143
|
+
origKfTime: kf.time,
|
|
1144
|
+
});
|
|
1145
|
+
}, onClick: () => {
|
|
1146
|
+
if (justDraggedRef.current || !el)
|
|
1147
|
+
return;
|
|
1148
|
+
seekLocal(elementTime(el) + kf.time);
|
|
1149
|
+
actions.setUiState({ curveTarget: { elementId: lane.elementId, property: lane.property } });
|
|
1150
|
+
} }, `kf-${lane.elementId}-${lane.property}-${kf.kfIndex}`));
|
|
1151
|
+
}))] })] })] }) }), menu && (_jsx(ClipMenu, { menu: menu, actions: menuActions(menu.id), close: () => setMenu(null) }))] }));
|
|
1152
|
+
}
|
|
1153
|
+
// ── Canvas draw helpers ───────────────────────────────────────────────
|
|
1154
|
+
const HANDLE_HIT = 14;
|
|
1155
|
+
function drawClip(ctx, clip, x, y, selected, hovered, theme, isLocked, peaks, pxPerSec, onMedia, ghost = false) {
|
|
1156
|
+
const el = clip.element;
|
|
1157
|
+
const sw = (PALETTE[el.type] ?? FALLBACK_SWATCHES)[theme];
|
|
1158
|
+
const w = clip.w;
|
|
1159
|
+
const h = clip.h;
|
|
1160
|
+
if (ghost) {
|
|
1161
|
+
// Origin placeholder while dragging: outline + fill color only, faded —
|
|
1162
|
+
// no waveform / filmstrip / icon / label (those ride the floating clip).
|
|
1163
|
+
ctx.globalAlpha = 0.4;
|
|
1164
|
+
roundRect(ctx, x, y, w, h, 6);
|
|
1165
|
+
ctx.fillStyle = sw.bg;
|
|
1166
|
+
ctx.fill();
|
|
1167
|
+
ctx.lineWidth = 1.5;
|
|
1168
|
+
ctx.strokeStyle = sw.border;
|
|
1169
|
+
ctx.stroke();
|
|
1170
|
+
ctx.globalAlpha = 1;
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
ctx.globalAlpha = isLocked ? 0.5 : hovered && !selected ? 0.92 : 1;
|
|
1174
|
+
roundRect(ctx, x, y, w, h, 6);
|
|
1175
|
+
ctx.fillStyle = sw.bg;
|
|
1176
|
+
ctx.fill();
|
|
1177
|
+
ctx.save();
|
|
1178
|
+
roundRect(ctx, x, y, w, h, 6);
|
|
1179
|
+
ctx.clip();
|
|
1180
|
+
// Filmstrip (video) — frames tiled across the body, sampled in the
|
|
1181
|
+
// browser (no backend). trim_start + playback_rate map clip-local
|
|
1182
|
+
// time → media time.
|
|
1183
|
+
if (el.type === 'video') {
|
|
1184
|
+
const url = getMediaUrl(el);
|
|
1185
|
+
if (url) {
|
|
1186
|
+
const trimStart = num(el.trim_start);
|
|
1187
|
+
const rate = num(el.playback_rate) || 1;
|
|
1188
|
+
const tileW = Math.max(40, Math.round(h * (16 / 9)));
|
|
1189
|
+
for (let tx = 0; tx < w; tx += tileW) {
|
|
1190
|
+
const localSec = (tx + tileW / 2) / pxPerSec;
|
|
1191
|
+
const mediaTime = trimStart + localSec * rate;
|
|
1192
|
+
const frame = filmstripFrame(url, mediaTime, h, onMedia);
|
|
1193
|
+
if (frame) {
|
|
1194
|
+
const drawW = Math.min(tileW, w - tx);
|
|
1195
|
+
// Cover-crop the frame to the tile.
|
|
1196
|
+
const fAspect = frame.width / frame.height;
|
|
1197
|
+
const sw2 = Math.min(frame.width, drawW / tileW * frame.height * fAspect);
|
|
1198
|
+
ctx.drawImage(frame, 0, 0, sw2, frame.height, x + tx, y, drawW, h);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
// Subtle separators between frames.
|
|
1202
|
+
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
|
|
1203
|
+
ctx.lineWidth = 1;
|
|
1204
|
+
for (let tx = tileW; tx < w; tx += tileW) {
|
|
1205
|
+
ctx.beginPath();
|
|
1206
|
+
ctx.moveTo(x + tx, y);
|
|
1207
|
+
ctx.lineTo(x + tx, y + h);
|
|
1208
|
+
ctx.stroke();
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// Waveform (audio) — accent-tinted min/max bars, lower 2/3.
|
|
1213
|
+
if (peaks) {
|
|
1214
|
+
const trimStart = typeof el.trim_start === 'number'
|
|
1215
|
+
? el.trim_start
|
|
1216
|
+
: 0;
|
|
1217
|
+
drawWaveform(ctx, peaks, trimStart, clip.dur, x, y + h / 3, w, (h * 2) / 3, withAlpha(sw.selectedBorder, 0.5));
|
|
1218
|
+
}
|
|
1219
|
+
// Caption chunks — the windowed segments (one block per chunk, derived from
|
|
1220
|
+
// `max_length` via the SAME function the renderer uses, so they match). Falls
|
|
1221
|
+
// back to per-word ticks when there's no windowing (one chunk).
|
|
1222
|
+
if (el.type === 'caption' && Array.isArray(el.words)) {
|
|
1223
|
+
const win = Math.max(clip.dur, 0.001);
|
|
1224
|
+
const chunks = chunkCaptionWords(el.words, el.max_length);
|
|
1225
|
+
if (chunks.length > 1) {
|
|
1226
|
+
for (const c of chunks) {
|
|
1227
|
+
const cx = x + 3 + (c.start / win) * (w - 6);
|
|
1228
|
+
const cw = Math.max(3, ((c.end - c.start) / win) * (w - 6) - 2);
|
|
1229
|
+
ctx.fillStyle = withAlpha(sw.selectedBorder, 0.3);
|
|
1230
|
+
roundRect(ctx, cx, y + 4, cw, h - 8, 2);
|
|
1231
|
+
ctx.fill();
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
else {
|
|
1235
|
+
ctx.fillStyle = withAlpha(sw.selectedBorder, 0.55);
|
|
1236
|
+
for (const word of el.words) {
|
|
1237
|
+
const wx = x + 4 + (word.start / win) * (w - 8);
|
|
1238
|
+
const ww = Math.max(1, ((word.end - word.start) / win) * (w - 8) - 1);
|
|
1239
|
+
ctx.fillRect(wx, y + h - 6, ww, 3);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
// Volume rubber-band — AUDIO only (video shows a filmstrip; stacking
|
|
1244
|
+
// the gain line over it cramps both — ruled by Ian 2026-06-12; video
|
|
1245
|
+
// audio still adjusts in the inspector). Static numeric volume only.
|
|
1246
|
+
if (el.type === 'audio') {
|
|
1247
|
+
const vol = el.volume;
|
|
1248
|
+
if (typeof vol === 'number' || vol === undefined) {
|
|
1249
|
+
const v = typeof vol === 'number' ? vol : 100;
|
|
1250
|
+
const ly = y + (1 - Math.min(1, Math.max(0, v / VOL_MAX))) * h;
|
|
1251
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
|
1252
|
+
ctx.lineWidth = 1;
|
|
1253
|
+
ctx.beginPath();
|
|
1254
|
+
ctx.moveTo(x, ly);
|
|
1255
|
+
ctx.lineTo(x + w, ly);
|
|
1256
|
+
ctx.stroke();
|
|
1257
|
+
if (hovered || selected) {
|
|
1258
|
+
ctx.fillStyle = 'rgba(255,255,255,0.92)';
|
|
1259
|
+
ctx.beginPath();
|
|
1260
|
+
ctx.arc(x + w / 2, ly, 3, 0, Math.PI * 2);
|
|
1261
|
+
ctx.fill();
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
// Type icon (accent) + label. A 16px left inset clears the resize-
|
|
1266
|
+
// handle zone (bars live at x+4…x+10) so the icon never overlaps it.
|
|
1267
|
+
const inset = x + 16;
|
|
1268
|
+
let labelX = inset;
|
|
1269
|
+
if (w > 52) {
|
|
1270
|
+
drawTypeIcon(ctx, el.type, inset, y + h / 2 - 7, sw.selectedBorder);
|
|
1271
|
+
labelX = inset + 22;
|
|
1272
|
+
}
|
|
1273
|
+
ctx.fillStyle = sw.text;
|
|
1274
|
+
ctx.fillText(elementLabel(el), labelX, y + h / 2);
|
|
1275
|
+
ctx.restore();
|
|
1276
|
+
// Border (over the fill/waveform).
|
|
1277
|
+
roundRect(ctx, x, y, w, h, 6);
|
|
1278
|
+
ctx.lineWidth = selected ? 2 : 1.5;
|
|
1279
|
+
ctx.strokeStyle = selected ? sw.selectedBorder : sw.border;
|
|
1280
|
+
ctx.stroke();
|
|
1281
|
+
// Resize handles — double bars at each edge, on hover/selection.
|
|
1282
|
+
// NON-AUDIO only (ruled by Ian 2026-06-12): audio reserves its
|
|
1283
|
+
// corners for the fade grab-dots, so it stays cursor-only trim.
|
|
1284
|
+
if (el.type !== 'audio' && (selected || hovered) && !isLocked && w > HANDLE_HIT * 2 + 8) {
|
|
1285
|
+
drawHandle(ctx, x + 4, y + h / 2, sw.selectedBorder);
|
|
1286
|
+
drawHandle(ctx, x + w - 10, y + h / 2, sw.selectedBorder);
|
|
1287
|
+
}
|
|
1288
|
+
// Fade tapers + grab dots — AUDIO only (same reason as the gain
|
|
1289
|
+
// line). Taper draws when a fade is set; dots on hover/selection.
|
|
1290
|
+
if (el.type === 'audio') {
|
|
1291
|
+
const fin = num(el.audio_fade_in);
|
|
1292
|
+
const fout = num(el.audio_fade_out);
|
|
1293
|
+
const finPx = Math.min(w, fin * pxPerSec);
|
|
1294
|
+
const foutPx = Math.min(w, fout * pxPerSec);
|
|
1295
|
+
ctx.strokeStyle = sw.selectedBorder;
|
|
1296
|
+
ctx.lineWidth = 1.5;
|
|
1297
|
+
if (fin > 0) {
|
|
1298
|
+
ctx.beginPath();
|
|
1299
|
+
ctx.moveTo(x, y + h);
|
|
1300
|
+
ctx.lineTo(x + finPx, y);
|
|
1301
|
+
ctx.stroke();
|
|
1302
|
+
}
|
|
1303
|
+
if (fout > 0) {
|
|
1304
|
+
ctx.beginPath();
|
|
1305
|
+
ctx.moveTo(x + w - foutPx, y);
|
|
1306
|
+
ctx.lineTo(x + w, y + h);
|
|
1307
|
+
ctx.stroke();
|
|
1308
|
+
}
|
|
1309
|
+
if ((hovered || selected) && !isLocked) {
|
|
1310
|
+
fadeDot(ctx, x + finPx, y, sw.selectedBorder);
|
|
1311
|
+
fadeDot(ctx, x + w - foutPx, y, sw.selectedBorder);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
ctx.globalAlpha = 1;
|
|
1315
|
+
}
|
|
1316
|
+
/** Two thin vertical accent bars — the legacy resize-handle look. */
|
|
1317
|
+
function drawHandle(ctx, x, cy, color) {
|
|
1318
|
+
ctx.fillStyle = color;
|
|
1319
|
+
ctx.fillRect(x, cy - 6, 2, 12);
|
|
1320
|
+
ctx.fillRect(x + 4, cy - 6, 2, 12);
|
|
1321
|
+
}
|
|
1322
|
+
/** A grab dot on the fade line — accent fill, white ring. */
|
|
1323
|
+
function fadeDot(ctx, cx, cy, color) {
|
|
1324
|
+
ctx.beginPath();
|
|
1325
|
+
ctx.arc(cx, cy, 3.5, 0, Math.PI * 2);
|
|
1326
|
+
ctx.fillStyle = color;
|
|
1327
|
+
ctx.fill();
|
|
1328
|
+
ctx.lineWidth = 1.5;
|
|
1329
|
+
ctx.strokeStyle = '#fff';
|
|
1330
|
+
ctx.stroke();
|
|
1331
|
+
}
|
|
1332
|
+
function num(v) {
|
|
1333
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
1334
|
+
}
|
|
1335
|
+
/** Per-type icon (stroke paths from Clip.tsx) at a 14px box from
|
|
1336
|
+
* (ix, iy), in the accent color. */
|
|
1337
|
+
function drawTypeIcon(ctx, type, ix, iy, color) {
|
|
1338
|
+
ctx.save();
|
|
1339
|
+
ctx.translate(ix, iy);
|
|
1340
|
+
ctx.scale(14 / 16, 14 / 16);
|
|
1341
|
+
ctx.strokeStyle = color;
|
|
1342
|
+
ctx.fillStyle = color;
|
|
1343
|
+
ctx.lineWidth = 1.6;
|
|
1344
|
+
ctx.lineCap = 'round';
|
|
1345
|
+
ctx.lineJoin = 'round';
|
|
1346
|
+
const stroke = (d) => ctx.stroke(new Path2D(d));
|
|
1347
|
+
const rr = (rx, ry, rw, rh, r) => {
|
|
1348
|
+
const p = new Path2D();
|
|
1349
|
+
p.roundRect(rx, ry, rw, rh, r);
|
|
1350
|
+
ctx.stroke(p);
|
|
1351
|
+
};
|
|
1352
|
+
switch (type) {
|
|
1353
|
+
case 'video':
|
|
1354
|
+
rr(2, 4, 9, 8, 1.5);
|
|
1355
|
+
stroke('M11 6 L14 4 V12 L11 10 Z');
|
|
1356
|
+
break;
|
|
1357
|
+
case 'audio':
|
|
1358
|
+
stroke('M3 7 V9 M5.5 5 V11 M8 3 V13 M10.5 5 V11 M13 7 V9');
|
|
1359
|
+
break;
|
|
1360
|
+
case 'shape':
|
|
1361
|
+
case 'particles':
|
|
1362
|
+
rr(2.5, 2.5, 11, 11, 2);
|
|
1363
|
+
break;
|
|
1364
|
+
case 'image': {
|
|
1365
|
+
rr(2, 3, 12, 10, 1.5);
|
|
1366
|
+
ctx.beginPath();
|
|
1367
|
+
ctx.arc(6, 6.5, 1.2, 0, Math.PI * 2);
|
|
1368
|
+
ctx.fill();
|
|
1369
|
+
stroke('M2.5 11.5 L6 8 L9 11 L11.5 9 L13.5 11');
|
|
1370
|
+
break;
|
|
1371
|
+
}
|
|
1372
|
+
case 'caption':
|
|
1373
|
+
rr(2, 3.5, 12, 9, 1.5);
|
|
1374
|
+
stroke('M4.5 8.5 H7 M9 8.5 H11.5 M4.5 10.5 H6 M8 10.5 H11.5');
|
|
1375
|
+
break;
|
|
1376
|
+
case 'group':
|
|
1377
|
+
stroke('M8 2 L14 5 L8 8 L2 5 Z M2 8 L8 11 L14 8 M2 11 L8 14 L14 11');
|
|
1378
|
+
break;
|
|
1379
|
+
default: // text
|
|
1380
|
+
stroke('M3 4 H13 M8 4 V13');
|
|
1381
|
+
}
|
|
1382
|
+
ctx.restore();
|
|
1383
|
+
}
|
|
1384
|
+
/** Min/max peak bars (mirrors Waveform.tsx) into [x, x+w] × [y, y+h]. */
|
|
1385
|
+
function drawWaveform(ctx, wf, trimStart, windowSec, x, y, w, h, color) {
|
|
1386
|
+
if (windowSec <= 0 || w <= 0)
|
|
1387
|
+
return;
|
|
1388
|
+
const startBucket = trimStart * wf.peaksPerSecond;
|
|
1389
|
+
const bucketsPerPx = (windowSec * wf.peaksPerSecond) / w;
|
|
1390
|
+
const mid = y + h / 2;
|
|
1391
|
+
const total = wf.peaks.length / 2;
|
|
1392
|
+
ctx.fillStyle = color;
|
|
1393
|
+
for (let px = 0; px < w; px++) {
|
|
1394
|
+
const b0 = Math.floor(startBucket + px * bucketsPerPx);
|
|
1395
|
+
const b1 = Math.max(b0 + 1, Math.floor(startBucket + (px + 1) * bucketsPerPx));
|
|
1396
|
+
let mn = 0;
|
|
1397
|
+
let mx = 0;
|
|
1398
|
+
for (let b = b0; b < b1 && b < total; b++) {
|
|
1399
|
+
if (b < 0)
|
|
1400
|
+
continue;
|
|
1401
|
+
mn = Math.min(mn, wf.peaks[b * 2]);
|
|
1402
|
+
mx = Math.max(mx, wf.peaks[b * 2 + 1]);
|
|
1403
|
+
}
|
|
1404
|
+
const y0 = mid - mx * (h / 2 - 1);
|
|
1405
|
+
const y1 = mid - mn * (h / 2 - 1);
|
|
1406
|
+
ctx.fillRect(x + px, y0, 1, Math.max(1, y1 - y0));
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
function getMediaUrl(el) {
|
|
1410
|
+
return (el.type === 'audio' || el.type === 'video') &&
|
|
1411
|
+
typeof el.source === 'string'
|
|
1412
|
+
? (el.source)
|
|
1413
|
+
: null;
|
|
1414
|
+
}
|
|
1415
|
+
/** #rrggbb(aa) → rgba() at the given alpha. */
|
|
1416
|
+
function withAlpha(hex, a) {
|
|
1417
|
+
const m = /^#?([0-9a-fA-F]{6})/.exec(hex);
|
|
1418
|
+
if (!m)
|
|
1419
|
+
return hex;
|
|
1420
|
+
const n = parseInt(m[1], 16);
|
|
1421
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
|
|
1422
|
+
}
|
|
1423
|
+
function drawDiamond(ctx, cx, cy, r, color) {
|
|
1424
|
+
ctx.fillStyle = color;
|
|
1425
|
+
ctx.beginPath();
|
|
1426
|
+
ctx.moveTo(cx, cy - r);
|
|
1427
|
+
ctx.lineTo(cx + r, cy);
|
|
1428
|
+
ctx.lineTo(cx, cy + r);
|
|
1429
|
+
ctx.lineTo(cx - r, cy);
|
|
1430
|
+
ctx.closePath();
|
|
1431
|
+
ctx.fill();
|
|
1432
|
+
}
|
|
1433
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
1434
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
1435
|
+
ctx.beginPath();
|
|
1436
|
+
ctx.moveTo(x + rr, y);
|
|
1437
|
+
ctx.arcTo(x + w, y, x + w, y + h, rr);
|
|
1438
|
+
ctx.arcTo(x + w, y + h, x, y + h, rr);
|
|
1439
|
+
ctx.arcTo(x, y + h, x, y, rr);
|
|
1440
|
+
ctx.arcTo(x, y, x + w, y, rr);
|
|
1441
|
+
ctx.closePath();
|
|
1442
|
+
}
|
|
1443
|
+
/** A keyframe lane's header row: property label + ◀ ◆ ▶ cluster.
|
|
1444
|
+
* Positioned absolutely in the canvas timeline's header column. */
|
|
1445
|
+
function LaneNav({ elementId, property, animIndex, top, height, }) {
|
|
1446
|
+
const { store } = useEditorContext();
|
|
1447
|
+
const actions = useEditor();
|
|
1448
|
+
const el = useEditorStore((s) => findById(s.source.elements, elementId));
|
|
1449
|
+
const anim = el?.keyframe_animations?.[animIndex];
|
|
1450
|
+
if (!el?.id || !anim)
|
|
1451
|
+
return null;
|
|
1452
|
+
const start = elementTime(el);
|
|
1453
|
+
const playTime = () => store.getState().playback.time;
|
|
1454
|
+
const jump = (dir) => {
|
|
1455
|
+
const t = playTime();
|
|
1456
|
+
const abs = anim.keyframes.map((k) => start + kfTime(k)).sort((a, b) => a - b);
|
|
1457
|
+
const target = dir === 1 ? abs.find((a) => a > t + KF_EPS) : [...abs].reverse().find((a) => a < t - KF_EPS);
|
|
1458
|
+
if (target !== undefined)
|
|
1459
|
+
actions.seek(Math.max(0, target));
|
|
1460
|
+
};
|
|
1461
|
+
const toggle = () => {
|
|
1462
|
+
const local = Math.round(Math.max(0, playTime() - start) * 1000) / 1000;
|
|
1463
|
+
actions.updateElement(el.id, {
|
|
1464
|
+
keyframe_animations: toggleKeyframeAt(el.keyframe_animations ?? [], animIndex, local),
|
|
1465
|
+
});
|
|
1466
|
+
};
|
|
1467
|
+
const navBtn = (dir, left) => (_jsx("button", { type: "button", className: "absolute pointer-events-auto grid place-items-center text-muted-foreground/60 hover:text-foreground", style: { left, top: top + height / 2 - 8, width: 14, height: 16 }, title: dir === -1 ? 'Previous keyframe' : 'Next keyframe', "aria-label": dir === -1 ? 'Previous keyframe' : 'Next keyframe', onClick: () => jump(dir), children: _jsx("svg", { width: "7", height: "7", viewBox: "0 0 8 8", "aria-hidden": "true", children: _jsx("path", { d: dir === -1 ? 'M6 1 L2 4 L6 7 Z' : 'M2 1 L6 4 L2 7 Z', fill: "currentColor" }) }) }));
|
|
1468
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "absolute pointer-events-auto text-[10px] text-muted-foreground hover:text-foreground truncate text-left", style: { left: 20, top, width: HEADER_W - 20 - 46, height, lineHeight: `${height}px` }, title: "Open in curve editor", onClick: () => actions.setUiState({ curveTarget: { elementId, property } }), children: property }), navBtn(-1, HEADER_W - 46), _jsx("button", { type: "button", className: "absolute pointer-events-auto grid place-items-center group", style: { left: HEADER_W - 31, top: top + height / 2 - 8, width: 16, height: 16 }, title: "Add / remove keyframe at playhead", "aria-label": `Toggle ${property} keyframe at playhead`, onClick: toggle, children: _jsx("span", { className: "w-1.5 h-1.5 rotate-45 group-hover:scale-125 transition-transform", style: { background: 'var(--color-playhead)' } }) }), navBtn(1, HEADER_W - 14)] }));
|
|
1469
|
+
}
|
|
1470
|
+
function ClipMenu({ menu, actions, close, }) {
|
|
1471
|
+
const item = 'w-full text-left px-2.5 h-7 text-[11px] text-foreground/90 hover:bg-card disabled:opacity-35 disabled:hover:bg-transparent';
|
|
1472
|
+
return (_jsxs("div", { className: "fixed z-50 min-w-36 py-1 rounded-md border bg-popover shadow-2xl", style: { left: menu.x, top: menu.y, borderColor: 'var(--color-popover-border)' }, onMouseDown: (e) => e.stopPropagation(), children: [_jsx("button", { type: "button", className: item, disabled: !actions.canSplit, onClick: () => { actions.split(); close(); }, children: "Split at playhead" }), _jsx("button", { type: "button", className: item, onClick: () => { actions.duplicate(); close(); }, children: "Duplicate" }), actions.canGroup && (_jsx("button", { type: "button", className: item, onClick: () => { actions.group(); close(); }, children: "Group selection" })), actions.isGroup && (_jsx("button", { type: "button", className: item, onClick: () => { actions.enter(); close(); }, children: "Enter group" })), actions.isGroup && (_jsx("button", { type: "button", className: item, onClick: () => { actions.ungroup(); close(); }, children: "Ungroup" })), _jsx("div", { className: "h-px bg-border my-1" }), _jsx("button", { type: "button", className: cn(item, 'text-destructive hover:text-destructive'), onClick: () => { actions.remove(); close(); }, children: "Delete" })] }));
|
|
1473
|
+
}
|
|
1474
|
+
// ── Split math (transplanted, exact) ─────────────────────────────────
|
|
1475
|
+
function splitElement(el, at, sourceDuration) {
|
|
1476
|
+
const start = elementTime(el);
|
|
1477
|
+
const dur = elementDuration(el, sourceDuration);
|
|
1478
|
+
const end = start + dur;
|
|
1479
|
+
if (at <= start + 0.05 || at >= end - 0.05)
|
|
1480
|
+
return null;
|
|
1481
|
+
if (el.time_remap)
|
|
1482
|
+
return null;
|
|
1483
|
+
const off = at - start;
|
|
1484
|
+
const a = { ...el, duration: round3(off) };
|
|
1485
|
+
const b = {
|
|
1486
|
+
...el,
|
|
1487
|
+
id: `${el.id}-b`,
|
|
1488
|
+
time: round3(at),
|
|
1489
|
+
duration: round3(end - at),
|
|
1490
|
+
};
|
|
1491
|
+
if (el.type === 'video' || el.type === 'audio') {
|
|
1492
|
+
const rate = el.type === 'video' && typeof el.playback_rate === 'number'
|
|
1493
|
+
? el.playback_rate
|
|
1494
|
+
: 1;
|
|
1495
|
+
const trim0 = typeof el.trim_start === 'number'
|
|
1496
|
+
? el.trim_start
|
|
1497
|
+
: 0;
|
|
1498
|
+
b.trim_start = round3(trim0 + off * rate);
|
|
1499
|
+
const td = el.trim_duration;
|
|
1500
|
+
if (typeof td === 'number') {
|
|
1501
|
+
a.trim_duration = round3(Math.min(td, off * rate));
|
|
1502
|
+
b.trim_duration = round3(Math.max(0.01, td - off * rate));
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
if (el.keyframe_animations) {
|
|
1506
|
+
b.keyframe_animations = el.keyframe_animations.map((an) => ({
|
|
1507
|
+
...an,
|
|
1508
|
+
keyframes: an.keyframes.map((k) => typeof k.time === 'number' ? { ...k, time: round3(k.time - off) } : k),
|
|
1509
|
+
}));
|
|
1510
|
+
}
|
|
1511
|
+
if (el.animations) {
|
|
1512
|
+
b.animations = el.animations.map((an) => typeof an.time === 'number' ? { ...an, time: round3(an.time - off) } : an);
|
|
1513
|
+
}
|
|
1514
|
+
return [a, b];
|
|
1515
|
+
}
|
|
1516
|
+
function findById(elements, id) {
|
|
1517
|
+
for (const el of elements) {
|
|
1518
|
+
if (el.id === id)
|
|
1519
|
+
return el;
|
|
1520
|
+
if (el.type === 'group') {
|
|
1521
|
+
const nested = findById(el.elements, id);
|
|
1522
|
+
if (nested)
|
|
1523
|
+
return nested;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
function byKfTime(a, b) {
|
|
1529
|
+
const ta = typeof a.time === 'number' ? a.time : parseFloat(String(a.time)) || 0;
|
|
1530
|
+
const tb = typeof b.time === 'number' ? b.time : parseFloat(String(b.time)) || 0;
|
|
1531
|
+
return ta - tb;
|
|
1532
|
+
}
|
|
1533
|
+
function round3(v) {
|
|
1534
|
+
return Math.round(v * 1000) / 1000;
|
|
1535
|
+
}
|
|
1536
|
+
//# sourceMappingURL=CanvasTimeline.js.map
|