@bycrux/editor 0.12.0 → 1.0.1
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/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
import { useRef, type ComponentType, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
AlignCenterHorizontal,
|
|
4
|
+
AlignCenterVertical,
|
|
5
|
+
AlignEndHorizontal,
|
|
6
|
+
AlignEndVertical,
|
|
7
|
+
AlignStartHorizontal,
|
|
8
|
+
AlignStartVertical,
|
|
9
|
+
ChevronLeft,
|
|
10
|
+
ChevronRight,
|
|
11
|
+
Diamond,
|
|
12
|
+
RotateCcw,
|
|
13
|
+
} from 'lucide-react'
|
|
14
|
+
import type { KeyframeProp, VisualItem } from '../schema'
|
|
15
|
+
import {
|
|
16
|
+
canKeyframe,
|
|
17
|
+
disableKeyframing,
|
|
18
|
+
enableKeyframing,
|
|
19
|
+
hasKeyframes,
|
|
20
|
+
isUniformScale,
|
|
21
|
+
removeKeyframe,
|
|
22
|
+
setKeyframe,
|
|
23
|
+
trackFor,
|
|
24
|
+
transformProps,
|
|
25
|
+
valueAt,
|
|
26
|
+
canKeyframeProp,
|
|
27
|
+
} from './keyframeOps'
|
|
28
|
+
import { usePlaybackTime, type PlaybackClock } from './playback-clock'
|
|
29
|
+
import { NumberField, Slider, cn, stepValue } from '../ui'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `<OverlayInspector>` — the editor's contextual right-hand **Transform**
|
|
33
|
+
* properties panel for the selected overlay. One section holding the
|
|
34
|
+
* keyframeable transform props as a scale slider + percent box, a
|
|
35
|
+
* position pair, a rotation box with a circular dial, an opacity box, and a
|
|
36
|
+
* six-button align row — plus a per-property keyframe unit (`‹ ◇ ›`) and an
|
|
37
|
+
* all-properties one in the section header.
|
|
38
|
+
*
|
|
39
|
+
* WHICH props those are depends on the item: an overlay scaled uniformly
|
|
40
|
+
* carries `scale`, one scaled per-axis carries `scaleX`/`scaleY` instead, and
|
|
41
|
+
* the Uniform-scale toggle moves an item between the two. See `uniform` and
|
|
42
|
+
* `allProps` below — that split runs through the header actions as well as
|
|
43
|
+
* the Scale row itself.
|
|
44
|
+
*
|
|
45
|
+
* Every control obeys the SAME CapCut-style auto-keyframe rule, factored into
|
|
46
|
+
* {@link writeProp}: editing a prop that's already keyframed drops a keyframe
|
|
47
|
+
* at the playhead instead of overwriting the static scalar; editing an
|
|
48
|
+
* unkeyframed prop writes the scalar. Continuous gestures (typing, slider
|
|
49
|
+
* drag, dial drag) preview per change and commit once when the gesture ends;
|
|
50
|
+
* discrete actions (steppers, align, reset, diamonds) fire one `onChange`.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately the ONLY place opacity is editable at all — the preview's drag
|
|
53
|
+
* gestures (useDragOverlay) cover offset/scale/rotation but never opacity, so
|
|
54
|
+
* without this panel opacity could never be keyframed either.
|
|
55
|
+
*
|
|
56
|
+
* Props-driven and host-agnostic, like the rest of this package: it never
|
|
57
|
+
* reaches into a store. All persistence flows back through the three
|
|
58
|
+
* callbacks, which the host (VideoEditor) wires to its own sync core.
|
|
59
|
+
*/
|
|
60
|
+
export interface OverlayInspectorProps {
|
|
61
|
+
/** The single selected item, or null. Non-overlay items and null both
|
|
62
|
+
* render the empty state — the component enforces its own "overlay only"
|
|
63
|
+
* contract rather than trusting the caller to have already filtered. */
|
|
64
|
+
item: VisualItem | null
|
|
65
|
+
/** The shared playback clock. Subscribed HERE (usePlaybackTime), not by the
|
|
66
|
+
* caller — so a ~60Hz playhead tick re-renders only this small panel
|
|
67
|
+
* instead of the whole editor (see playback-clock.ts's doc comment). */
|
|
68
|
+
clock: PlaybackClock
|
|
69
|
+
/** Live-preview a continuously-typed edit: no undo entry, no save yet.
|
|
70
|
+
* The same contract the Content tab's fields use (OverlayContentPanel) —
|
|
71
|
+
* both routed through VideoEditor's previewOverlayProps. */
|
|
72
|
+
onPreview: (item: VisualItem) => void
|
|
73
|
+
/** Commit the last previewed edit as one undo step + queued save. Fired on
|
|
74
|
+
* the number input's blur, closing the typing gesture. */
|
|
75
|
+
onCommit: () => void
|
|
76
|
+
/** A discrete, already-final edit — the keyframe diamond toggle. Persisted
|
|
77
|
+
* immediately; there is no separate blur to commit on. */
|
|
78
|
+
onChange: (item: VisualItem) => void
|
|
79
|
+
/** Move the playhead to an absolute timeline time. Used only by the keyframe
|
|
80
|
+
* navigation arrows. Optional: a host that hasn't wired seeking yet gets the
|
|
81
|
+
* arrows rendered but disabled, rather than arrows that silently do nothing. */
|
|
82
|
+
onSeek?: (time: number) => void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface RowSpec {
|
|
86
|
+
prop: KeyframeProp
|
|
87
|
+
/** The control's ACCESSIBLE name (`aria-label` on the number box, and the
|
|
88
|
+
* `${name}` in every diamond/arrow/stepper label built from this row).
|
|
89
|
+
* Deliberately separate from the VISIBLE label, which the redesign changed
|
|
90
|
+
* ("Offset X" is now shown as a `Position` row with an `X` prefix): the
|
|
91
|
+
* accessible names are the panel's behavioural contract and must not
|
|
92
|
+
* drift, so the visible chrome can be restyled freely without touching
|
|
93
|
+
* what assistive tech — or the behaviour tests — see. */
|
|
94
|
+
name: string
|
|
95
|
+
step: number
|
|
96
|
+
min?: number
|
|
97
|
+
max?: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Offsets/rotation are unbounded (percent of frame / degrees, can go negative
|
|
101
|
+
// or past 100/360 for effects); scale only needs a floor above zero; opacity
|
|
102
|
+
// is clamped to the 0–1 range the renderer expects.
|
|
103
|
+
const ROWS: Record<KeyframeProp, RowSpec> = {
|
|
104
|
+
offsetX: { prop: 'offsetX', name: 'Offset X', step: 1 },
|
|
105
|
+
offsetY: { prop: 'offsetY', name: 'Offset Y', step: 1 },
|
|
106
|
+
scale: { prop: 'scale', name: 'Scale', step: 0.01, min: 0.01 },
|
|
107
|
+
scaleX: { prop: 'scaleX', name: 'Scale X', step: 0.01, min: 0.01 },
|
|
108
|
+
scaleY: { prop: 'scaleY', name: 'Scale Y', step: 0.01, min: 0.01 },
|
|
109
|
+
rotation: { prop: 'rotation', name: 'Rotation', step: 1 },
|
|
110
|
+
opacity: { prop: 'opacity', name: 'Opacity', step: 0.01, min: 0, max: 1 },
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Identity transform. Kept in step with `geometryAt`'s defaults in
|
|
114
|
+
* `@bycrux/timeline-core` — the same values `valueAt` falls back to when a
|
|
115
|
+
* prop has neither a track nor a static scalar. */
|
|
116
|
+
const DEFAULTS: Record<KeyframeProp, number> = {
|
|
117
|
+
offsetX: 0,
|
|
118
|
+
offsetY: 0,
|
|
119
|
+
scale: 1,
|
|
120
|
+
scaleX: 1,
|
|
121
|
+
scaleY: 1,
|
|
122
|
+
rotation: 0,
|
|
123
|
+
opacity: 1,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Range the scale SLIDER spans. Wider than a sane layout needs on both ends
|
|
127
|
+
* so the handle never pins at an edge mid-animation; the number boxes stay
|
|
128
|
+
* the way to reach a value outside it (they only carry ROWS.scale's floor). */
|
|
129
|
+
const SCALE_SLIDER_MIN = 0.05
|
|
130
|
+
const SCALE_SLIDER_MAX = 4
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The single write rule this whole panel obeys. Auto-keyframe: a prop that is
|
|
134
|
+
* already animated gets a NEW keyframe at the playhead, so editing it mid-
|
|
135
|
+
* animation refines the curve instead of silently detaching the value from
|
|
136
|
+
* it; a prop that isn't animated just takes the static scalar.
|
|
137
|
+
*
|
|
138
|
+
* Every control routes through here — typing, slider, dial, steppers, align,
|
|
139
|
+
* reset — so there is exactly one place that decision is made and none of
|
|
140
|
+
* them can drift from the others.
|
|
141
|
+
*/
|
|
142
|
+
function writeProp(item: VisualItem, prop: KeyframeProp, localT: number, value: number): VisualItem {
|
|
143
|
+
return hasKeyframes(item, prop)
|
|
144
|
+
? setKeyframe(item, prop, localT, value)
|
|
145
|
+
: { ...item, [prop]: value }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Remove one per-axis scale from `item` ENTIRELY — the static scalar AND any
|
|
150
|
+
* keyframe track — so the resolver falls back to uniform `scale` again.
|
|
151
|
+
*
|
|
152
|
+
* The one write in this panel that does NOT go through {@link writeProp}, and
|
|
153
|
+
* it cannot: `writeProp` writes VALUES, and no value of `scaleX` means
|
|
154
|
+
* "absent" — 1 still shadows a `scale` of 1.2. `disableKeyframing` is no help
|
|
155
|
+
* either despite dropping the track, because its whole job is to write the
|
|
156
|
+
* sampled value into the static scalar so nothing jumps, and that scalar is
|
|
157
|
+
* exactly the field that has to go.
|
|
158
|
+
*
|
|
159
|
+
* Peeling the points off with `removeKeyframe` IS the documented way to drop a
|
|
160
|
+
* track without that scalar write: its last-point branch routes through
|
|
161
|
+
* `withTrack`, which removes the track (and `item.keyframes` itself when it
|
|
162
|
+
* was the last one). The `delete` then clears the scalar off a COPY — the
|
|
163
|
+
* input item is never mutated, matching keyframeOps' contract.
|
|
164
|
+
*
|
|
165
|
+
* Returns the same item when there was nothing to clear, so reference equality
|
|
166
|
+
* still tells a caller a no-op happened.
|
|
167
|
+
*/
|
|
168
|
+
function clearScaleAxis(item: VisualItem, prop: 'scaleX' | 'scaleY'): VisualItem {
|
|
169
|
+
let next = item
|
|
170
|
+
// Times read off the ORIGINAL track, which `removeKeyframe` never mutates;
|
|
171
|
+
// each `t` is distinct (normalizeTrack's invariant) so one pass clears all.
|
|
172
|
+
for (const point of trackFor(item, prop)?.points ?? []) next = removeKeyframe(next, prop, point.t)
|
|
173
|
+
if (next[prop] === undefined) return next
|
|
174
|
+
const stripped = { ...next }
|
|
175
|
+
delete stripped[prop]
|
|
176
|
+
return stripped
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The offset (percent of frame) that puts the overlay's edge on the frame's
|
|
180
|
+
* edge for `edge` = -1 (left/top), 0 (center/middle), +1 (right/bottom).
|
|
181
|
+
* Mirrors useDragOverlay's edge-snap: an overlay at scale >= 1 already covers
|
|
182
|
+
* the frame, so it has no edge to align to and every alignment collapses to
|
|
183
|
+
* centered rather than pushing it further off-frame.
|
|
184
|
+
*
|
|
185
|
+
* PER-AXIS by the caller, not by this function: `scale` is whichever axis'
|
|
186
|
+
* scale the alignment being computed runs along — `scaleX` for the
|
|
187
|
+
* left/center/right buttons (which write `offsetX`, a percent of frame
|
|
188
|
+
* WIDTH), `scaleY` for top/middle/bottom. Keeping the signature one scalar
|
|
189
|
+
* rather than taking the whole geometry keeps this a self-contained bit of
|
|
190
|
+
* arithmetic the tests can drive directly; the axis choice lives at the one
|
|
191
|
+
* call site, in the align button map. */
|
|
192
|
+
export function alignedOffset(scale: number, edge: -1 | 0 | 1): number {
|
|
193
|
+
const magnitude = Math.max(0, (0.5 - scale / 2) * 100)
|
|
194
|
+
// The `+ 0` normalizes the NEGATIVE zero `-1 * 0` produces at scale >= 1.
|
|
195
|
+
// It renders and serializes identically to 0, but it is not `Object.is`-
|
|
196
|
+
// equal to it, so an item carrying `offsetX: -0` compares unequal to an
|
|
197
|
+
// otherwise-identical one in any structural diff (version compare, undo
|
|
198
|
+
// dedupe) that reaches for strict equality.
|
|
199
|
+
return edge * magnitude + 0
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Curve-sampled values can carry float noise (e.g. 29.999999999999996) picked
|
|
203
|
+
// up from interpolation. Rounds only what's DISPLAYED — a typed edit is parsed
|
|
204
|
+
// from the operator's own text (NumberField), never from this rounded number.
|
|
205
|
+
// The steppers round their RESULT to the same 2dp, in `stepValue`: `1.2 + 0.01`
|
|
206
|
+
// lands on 1.2100000000000002 otherwise, and a stepper is supposed to be exact.
|
|
207
|
+
function displayValue(value: number): number {
|
|
208
|
+
return Math.round(value * 100) / 100
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Normalize degrees into `[0, 360)`. Matches useDragOverlay's rotate handler
|
|
212
|
+
* so the dial and the preview's rotate handle agree on what "45 degrees" is. */
|
|
213
|
+
function normalizeDegrees(deg: number): number {
|
|
214
|
+
return ((deg % 360) + 360) % 360
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Shared chrome ────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
const ROW_LABEL_CLASS = 'w-14 shrink-0 text-[11px] text-[var(--editor-text)]/55'
|
|
220
|
+
const ICON_BUTTON_CLASS =
|
|
221
|
+
'flex h-5 w-5 shrink-0 items-center justify-center rounded text-[var(--editor-text)]/45 transition-colors hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)] disabled:pointer-events-none disabled:opacity-25'
|
|
222
|
+
|
|
223
|
+
function IconButton({
|
|
224
|
+
label,
|
|
225
|
+
disabled,
|
|
226
|
+
onClick,
|
|
227
|
+
title,
|
|
228
|
+
children,
|
|
229
|
+
}: {
|
|
230
|
+
label: string
|
|
231
|
+
disabled?: boolean
|
|
232
|
+
onClick: () => void
|
|
233
|
+
title?: string
|
|
234
|
+
children: ReactNode
|
|
235
|
+
}) {
|
|
236
|
+
return (
|
|
237
|
+
<button type="button" aria-label={label} title={title} disabled={disabled} onClick={onClick} className={ICON_BUTTON_CLASS}>
|
|
238
|
+
{children}
|
|
239
|
+
</button>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
interface KeyframeNavProps {
|
|
244
|
+
prevLabel: string
|
|
245
|
+
nextLabel: string
|
|
246
|
+
diamondLabel: string
|
|
247
|
+
pressed: boolean
|
|
248
|
+
canPrev: boolean
|
|
249
|
+
canNext: boolean
|
|
250
|
+
onPrev: () => void
|
|
251
|
+
onNext: () => void
|
|
252
|
+
onDiamond: () => void
|
|
253
|
+
/** Greys the diamond out — the property exists but cannot be keyframed here. */
|
|
254
|
+
diamondDisabled?: boolean
|
|
255
|
+
/** Tooltip explaining WHY, shown only when disabled. */
|
|
256
|
+
diamondReason?: string
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The `‹ ◇ ›` unit: step to the previous/next keyframe, and toggle the
|
|
260
|
+
* keyframe at the playhead. One component so every row — and the header's
|
|
261
|
+
* all-props variant — lays out and labels the three controls identically. */
|
|
262
|
+
function KeyframeNav({
|
|
263
|
+
prevLabel, nextLabel, diamondLabel, pressed, canPrev, canNext, onPrev, onNext, onDiamond,
|
|
264
|
+
diamondDisabled = false, diamondReason,
|
|
265
|
+
}: KeyframeNavProps) {
|
|
266
|
+
return (
|
|
267
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
268
|
+
<IconButton label={prevLabel} disabled={!canPrev} onClick={onPrev}>
|
|
269
|
+
<ChevronLeft size={11} />
|
|
270
|
+
</IconButton>
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
aria-label={diamondLabel}
|
|
274
|
+
aria-pressed={pressed}
|
|
275
|
+
aria-disabled={diamondDisabled || undefined}
|
|
276
|
+
disabled={diamondDisabled}
|
|
277
|
+
title={diamondReason}
|
|
278
|
+
onClick={onDiamond}
|
|
279
|
+
className={cn(
|
|
280
|
+
'flex h-5 w-5 shrink-0 items-center justify-center rounded transition-colors',
|
|
281
|
+
// DISABLED, not hidden. A control that vanishes reads as a bug and
|
|
282
|
+
// invites a support question; one that is visibly unavailable and
|
|
283
|
+
// says why reads as a limitation, which is what it is.
|
|
284
|
+
diamondDisabled
|
|
285
|
+
? 'cursor-not-allowed text-[var(--editor-text)]/20'
|
|
286
|
+
: pressed
|
|
287
|
+
? 'text-[var(--editor-accent)]'
|
|
288
|
+
: 'text-[var(--editor-text)]/40 hover:text-[var(--editor-text)]/70',
|
|
289
|
+
)}
|
|
290
|
+
>
|
|
291
|
+
<Diamond size={11} fill={pressed ? 'currentColor' : 'none'} />
|
|
292
|
+
</button>
|
|
293
|
+
<IconButton label={nextLabel} disabled={!canNext} onClick={onNext}>
|
|
294
|
+
<ChevronRight size={11} />
|
|
295
|
+
</IconButton>
|
|
296
|
+
</div>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const DIAL_SIZE_PX = 40
|
|
301
|
+
const DIAL_CENTER = DIAL_SIZE_PX / 2
|
|
302
|
+
const DIAL_RADIUS = 14
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A circular rotation dial. 0 degrees points UP and positive is clockwise,
|
|
306
|
+
* matching `rotation`'s "degrees, clockwise" schema comment and the preview's
|
|
307
|
+
* rotate handle.
|
|
308
|
+
*
|
|
309
|
+
* Pointer drags use `setPointerCapture` so a drag that leaves the little
|
|
310
|
+
* 40px box still tracks. Keyboard is the accessible equivalent: arrows nudge
|
|
311
|
+
* 1 degree, Shift+arrow 15.
|
|
312
|
+
*/
|
|
313
|
+
function RotationDial({
|
|
314
|
+
rotation,
|
|
315
|
+
onPreview,
|
|
316
|
+
onCommit,
|
|
317
|
+
onChange,
|
|
318
|
+
}: {
|
|
319
|
+
rotation: number
|
|
320
|
+
onPreview: (deg: number) => void
|
|
321
|
+
onCommit: () => void
|
|
322
|
+
onChange: (deg: number) => void
|
|
323
|
+
}) {
|
|
324
|
+
const ref = useRef<SVGSVGElement>(null)
|
|
325
|
+
const dragging = useRef(false)
|
|
326
|
+
const previewed = useRef(false)
|
|
327
|
+
|
|
328
|
+
/** Degrees clockwise-from-up under the pointer, or null when the geometry
|
|
329
|
+
* can't support an angle. jsdom (and any `display:none` ancestor) hands
|
|
330
|
+
* back a 0x0 rect, and a pointer exactly on the centre has no direction —
|
|
331
|
+
* both bail rather than emitting a bogus angle. `setKeyframe`'s
|
|
332
|
+
* `Number.isFinite` guard would catch a NaN, but relying on a downstream
|
|
333
|
+
* guard to clean up after this one is how a NaN reaches the scalar path,
|
|
334
|
+
* which has no guard at all. */
|
|
335
|
+
function angleFrom(e: ReactPointerEvent): number | null {
|
|
336
|
+
const rect = ref.current?.getBoundingClientRect()
|
|
337
|
+
if (!rect || rect.width === 0 || rect.height === 0) return null
|
|
338
|
+
const dx = e.clientX - (rect.left + rect.width / 2)
|
|
339
|
+
const dy = e.clientY - (rect.top + rect.height / 2)
|
|
340
|
+
if (dx === 0 && dy === 0) return null
|
|
341
|
+
const deg = Math.atan2(dx, -dy) * (180 / Math.PI)
|
|
342
|
+
if (!Number.isFinite(deg)) return null
|
|
343
|
+
return normalizeDegrees(deg)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function onPointerDown(e: ReactPointerEvent<SVGSVGElement>) {
|
|
347
|
+
dragging.current = true
|
|
348
|
+
previewed.current = false
|
|
349
|
+
e.currentTarget.setPointerCapture?.(e.pointerId)
|
|
350
|
+
const deg = angleFrom(e)
|
|
351
|
+
if (deg === null) return
|
|
352
|
+
previewed.current = true
|
|
353
|
+
onPreview(deg)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function onPointerMove(e: ReactPointerEvent<SVGSVGElement>) {
|
|
357
|
+
if (!dragging.current) return
|
|
358
|
+
const deg = angleFrom(e)
|
|
359
|
+
if (deg === null) return
|
|
360
|
+
previewed.current = true
|
|
361
|
+
onPreview(deg)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function onPointerUp(e: ReactPointerEvent<SVGSVGElement>) {
|
|
365
|
+
if (!dragging.current) return
|
|
366
|
+
dragging.current = false
|
|
367
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId)
|
|
368
|
+
// Only close a gesture that actually previewed something — a click that
|
|
369
|
+
// never produced an angle must not spend an undo entry on nothing.
|
|
370
|
+
if (!previewed.current) return
|
|
371
|
+
previewed.current = false
|
|
372
|
+
onCommit()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function onKeyDown(e: ReactKeyboardEvent<SVGSVGElement>) {
|
|
376
|
+
const step = e.shiftKey ? 15 : 1
|
|
377
|
+
let delta = 0
|
|
378
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = -step
|
|
379
|
+
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = step
|
|
380
|
+
else return
|
|
381
|
+
e.preventDefault()
|
|
382
|
+
onChange(normalizeDegrees(rotation + delta))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// -90 puts 0 degrees at the top of the SVG; +sin/+cos then sweeps clockwise.
|
|
386
|
+
const rad = ((rotation - 90) * Math.PI) / 180
|
|
387
|
+
const handleX = DIAL_CENTER + Math.cos(rad) * DIAL_RADIUS
|
|
388
|
+
const handleY = DIAL_CENTER + Math.sin(rad) * DIAL_RADIUS
|
|
389
|
+
|
|
390
|
+
return (
|
|
391
|
+
<svg
|
|
392
|
+
ref={ref}
|
|
393
|
+
width={DIAL_SIZE_PX}
|
|
394
|
+
height={DIAL_SIZE_PX}
|
|
395
|
+
viewBox={`0 0 ${DIAL_SIZE_PX} ${DIAL_SIZE_PX}`}
|
|
396
|
+
role="slider"
|
|
397
|
+
aria-label="Rotation dial"
|
|
398
|
+
aria-valuemin={0}
|
|
399
|
+
aria-valuemax={360}
|
|
400
|
+
aria-valuenow={Math.round(rotation)}
|
|
401
|
+
tabIndex={0}
|
|
402
|
+
onPointerDown={onPointerDown}
|
|
403
|
+
onPointerMove={onPointerMove}
|
|
404
|
+
onPointerUp={onPointerUp}
|
|
405
|
+
onKeyDown={onKeyDown}
|
|
406
|
+
className="shrink-0 cursor-grab touch-none select-none rounded-full outline-none focus-visible:ring-1 focus-visible:ring-[var(--editor-accent)] active:cursor-grabbing"
|
|
407
|
+
>
|
|
408
|
+
<circle
|
|
409
|
+
cx={DIAL_CENTER}
|
|
410
|
+
cy={DIAL_CENTER}
|
|
411
|
+
r={DIAL_RADIUS + 2}
|
|
412
|
+
fill="var(--editor-surface)"
|
|
413
|
+
stroke="var(--editor-border)"
|
|
414
|
+
strokeWidth={1}
|
|
415
|
+
/>
|
|
416
|
+
<line
|
|
417
|
+
x1={DIAL_CENTER}
|
|
418
|
+
y1={DIAL_CENTER}
|
|
419
|
+
x2={handleX}
|
|
420
|
+
y2={handleY}
|
|
421
|
+
stroke="var(--editor-accent)"
|
|
422
|
+
strokeWidth={1.5}
|
|
423
|
+
strokeLinecap="round"
|
|
424
|
+
/>
|
|
425
|
+
<circle cx={handleX} cy={handleY} r={2.5} fill="var(--editor-accent)" />
|
|
426
|
+
</svg>
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
interface NumberCellProps {
|
|
431
|
+
row: RowSpec
|
|
432
|
+
/** Accessible-name override for the second box of a paired row (Scale Y). */
|
|
433
|
+
name?: string
|
|
434
|
+
/** Visible axis prefix. Purely decorative — the accessible name already
|
|
435
|
+
* carries the axis ("Offset X"), so this is hidden from assistive tech to
|
|
436
|
+
* avoid reading it twice. */
|
|
437
|
+
prefix?: string
|
|
438
|
+
value: number
|
|
439
|
+
onPreview: (value: number) => void
|
|
440
|
+
onCommit: () => void
|
|
441
|
+
onStep: (direction: 1 | -1) => void
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** A number box with its axis prefix and stepper — the panel's repeating unit.
|
|
445
|
+
* A thin binding onto the shared {@link NumberField}: this component's job is
|
|
446
|
+
* only to translate a `RowSpec` into the field's props (and to build the
|
|
447
|
+
* stable per-row `id`), not to own any of the box's behaviour.
|
|
448
|
+
*
|
|
449
|
+
* The `overlay-inspector-${name}` id it builds has no consumer today — no
|
|
450
|
+
* `<label htmlFor>` points at it, no test queries by it, nothing in
|
|
451
|
+
* `montaj_assets/ui` or `montaj_assets/render` reads it. That is deliberate,
|
|
452
|
+
* not dead code to prune: it is a stable, predictable hook for a future
|
|
453
|
+
* bound label, an automation script, or an external test harness to latch
|
|
454
|
+
* onto by row name, laid down now so it doesn't need inventing later. */
|
|
455
|
+
function NumberCell({ row, name = row.name, prefix, value, onPreview, onCommit, onStep }: NumberCellProps) {
|
|
456
|
+
return (
|
|
457
|
+
<NumberField
|
|
458
|
+
id={`overlay-inspector-${name.replace(/\s+/g, '-').toLowerCase()}`}
|
|
459
|
+
name={name}
|
|
460
|
+
prefix={prefix}
|
|
461
|
+
step={row.step}
|
|
462
|
+
min={row.min}
|
|
463
|
+
max={row.max}
|
|
464
|
+
value={displayValue(value)}
|
|
465
|
+
onPreview={onPreview}
|
|
466
|
+
onCommit={onCommit}
|
|
467
|
+
onStep={onStep}
|
|
468
|
+
className="h-7 w-16 px-1.5 text-xs"
|
|
469
|
+
/>
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A scale number box with its `%` suffix and stepper. Scale is shown as a
|
|
474
|
+
* PERCENTAGE (CapCut: 1 => 100%) while the stored scalar stays a multiplier,
|
|
475
|
+
* so only what is typed and displayed converts — `value` and `onStep` are
|
|
476
|
+
* still multiplier-side, exactly as the slider and `handleStep` always were.
|
|
477
|
+
* One component rather than the inline markup this used to be, because the
|
|
478
|
+
* unlocked Scale X / Scale Y rows have to convert, bound and step identically
|
|
479
|
+
* to the locked `Scale` row; three copies of the `* 100` would drift. */
|
|
480
|
+
function ScalePercentCell({
|
|
481
|
+
row, value, onPreview, onCommit, onStep,
|
|
482
|
+
}: {
|
|
483
|
+
row: RowSpec
|
|
484
|
+
value: number
|
|
485
|
+
onPreview: (value: number) => void
|
|
486
|
+
onCommit: () => void
|
|
487
|
+
onStep: (direction: 1 | -1) => void
|
|
488
|
+
}) {
|
|
489
|
+
return (
|
|
490
|
+
<NumberField
|
|
491
|
+
id={`overlay-inspector-${row.name.replace(/\s+/g, '-').toLowerCase()}`}
|
|
492
|
+
name={row.name}
|
|
493
|
+
// Percent-side bounds, matching the slider's span. Deliberately NOT
|
|
494
|
+
// ROWS.scale*'s multiplier `step`/`min`, which are what the STEPPER
|
|
495
|
+
// works in — hence `onStep` taking a direction rather than this
|
|
496
|
+
// component's own bounds deciding the nudge (see stepValue).
|
|
497
|
+
step={1}
|
|
498
|
+
min={5}
|
|
499
|
+
max={400}
|
|
500
|
+
unit="%"
|
|
501
|
+
value={Math.round(value * 100)}
|
|
502
|
+
onPreview={onPreview}
|
|
503
|
+
onCommit={onCommit}
|
|
504
|
+
onStep={onStep}
|
|
505
|
+
className="h-7 w-12 px-1.5 text-xs"
|
|
506
|
+
/>
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
|
|
511
|
+
|
|
512
|
+
export default function OverlayInspector({ item, clock, onPreview, onCommit, onChange, onSeek }: OverlayInspectorProps) {
|
|
513
|
+
// Subscribed unconditionally (hooks can't follow the early return below) —
|
|
514
|
+
// cheap: this component renders nothing else, so a tick that turns out to
|
|
515
|
+
// target a non-overlay/no selection still only re-runs this one function.
|
|
516
|
+
const playhead = usePlaybackTime(clock)
|
|
517
|
+
|
|
518
|
+
if (!canKeyframe(item)) {
|
|
519
|
+
return (
|
|
520
|
+
<div className={SECTION_CLASS}>
|
|
521
|
+
<div className="px-3 py-6 text-center text-[11px] text-[var(--editor-text)]/45">
|
|
522
|
+
Select an overlay to edit its properties.
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// A `const` alias so every closure below is narrowed to a non-null overlay
|
|
529
|
+
// without a `!` on each use (TypeScript does not carry a narrowing on a
|
|
530
|
+
// reassignable parameter into a nested function).
|
|
531
|
+
const target = item
|
|
532
|
+
|
|
533
|
+
// Clamped to the item's own span, matching `applyKeyframeMove`'s clamp in
|
|
534
|
+
// pointer-machine.ts. This panel renders whenever an overlay is SELECTED,
|
|
535
|
+
// and selecting an item does not move the playhead — so a playhead sitting
|
|
536
|
+
// outside the item's [start, end] (e.g. the overlay was selected while
|
|
537
|
+
// parked elsewhere on the timeline) would otherwise hand `setKeyframe`/
|
|
538
|
+
// `enableKeyframing` a negative or over-long `t`, writing a keyframe
|
|
539
|
+
// outside the span every OTHER keyframe consumer (draw, hit-test,
|
|
540
|
+
// keyframeOps) assumes points stay within.
|
|
541
|
+
const localT = Math.min(Math.max(0, playhead - target.start), Math.max(0, target.end - target.start))
|
|
542
|
+
|
|
543
|
+
// Every prop's value at the playhead, sampled ONCE off the incoming item.
|
|
544
|
+
// The header's all-props actions thread five or six writes through one
|
|
545
|
+
// another, so they must read from this snapshot rather than re-sampling a
|
|
546
|
+
// partially written item — otherwise one prop's write could perturb
|
|
547
|
+
// another's value. `scaleX`/`scaleY` are always populated even on a uniform
|
|
548
|
+
// item: `geometryAt` resolves them through `scale`, so they read as the
|
|
549
|
+
// overlay's actual per-axis size whether or not it carries the fields.
|
|
550
|
+
const sampled: Record<KeyframeProp, number> = {
|
|
551
|
+
offsetX: valueAt(target, 'offsetX', localT),
|
|
552
|
+
offsetY: valueAt(target, 'offsetY', localT),
|
|
553
|
+
scale: valueAt(target, 'scale', localT),
|
|
554
|
+
scaleX: valueAt(target, 'scaleX', localT),
|
|
555
|
+
scaleY: valueAt(target, 'scaleY', localT),
|
|
556
|
+
rotation: valueAt(target, 'rotation', localT),
|
|
557
|
+
opacity: valueAt(target, 'opacity', localT),
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Which scale the item is actually authored in, and therefore which prop
|
|
561
|
+
// list every all-props action walks. Both are DERIVED from the item on each
|
|
562
|
+
// render, never held in `useState`, like every other value this panel shows:
|
|
563
|
+
// this component is reconciled in place across selection changes, so state
|
|
564
|
+
// would keep answering for the previously selected overlay. The rule itself
|
|
565
|
+
// lives in keyframeOps — the canvas timeline's double-click-to-key gesture
|
|
566
|
+
// needs exactly the same answer, and two copies of it would drift.
|
|
567
|
+
const uniform = isUniformScale(target)
|
|
568
|
+
const allProps = transformProps(target)
|
|
569
|
+
|
|
570
|
+
/** Continuous gesture step — no undo entry yet. */
|
|
571
|
+
function preview(prop: KeyframeProp, value: number) {
|
|
572
|
+
onPreview(writeProp(target, prop, localT, value))
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** A discrete, already-final edit — one undo entry, no separate commit. */
|
|
576
|
+
function commitDiscrete(prop: KeyframeProp, value: number) {
|
|
577
|
+
onChange(writeProp(target, prop, localT, value))
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function handleToggle(prop: KeyframeProp) {
|
|
581
|
+
const next = hasKeyframes(target, prop)
|
|
582
|
+
? disableKeyframing(target, prop, localT)
|
|
583
|
+
: enableKeyframing(target, prop, localT)
|
|
584
|
+
onChange(next)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function handleStep(row: RowSpec, direction: 1 | -1) {
|
|
588
|
+
commitDiscrete(row.prop, stepValue(sampled[row.prop], direction, row))
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Ascending, de-duplicated keyframe times across `props`. */
|
|
592
|
+
function keyframeTimes(props: readonly KeyframeProp[]): number[] {
|
|
593
|
+
const times = new Set<number>()
|
|
594
|
+
for (const prop of props) for (const point of trackFor(target, prop)?.points ?? []) times.add(point.t)
|
|
595
|
+
return [...times].sort((a, b) => a - b)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Nav-unit wiring for a set of props. The arrows seek the playhead to an
|
|
599
|
+
* ABSOLUTE timeline time (`item.start + t`) — keyframe `t` is item-relative
|
|
600
|
+
* (see Keyframe.t) and the clock is not. Without `onSeek` they render
|
|
601
|
+
* disabled rather than silently doing nothing. */
|
|
602
|
+
function navFor(props: readonly KeyframeProp[]) {
|
|
603
|
+
const times = keyframeTimes(props)
|
|
604
|
+
let prev: number | undefined
|
|
605
|
+
for (const t of times) if (t < localT) prev = t // ascending, so the last one under localT wins
|
|
606
|
+
const next = times.find(t => t > localT)
|
|
607
|
+
return {
|
|
608
|
+
canPrev: !!onSeek && prev !== undefined,
|
|
609
|
+
canNext: !!onSeek && next !== undefined,
|
|
610
|
+
onPrev: () => { if (prev !== undefined) onSeek?.(target.start + prev) },
|
|
611
|
+
onNext: () => { if (next !== undefined) onSeek?.(target.start + next) },
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** True when EVERY transform prop has a keyframe at exactly the playhead —
|
|
616
|
+
* what the header diamond reflects, and the branch its click takes. Note
|
|
617
|
+
* this is a stricter test than the per-row diamonds' `hasKeyframes`, which
|
|
618
|
+
* only asks whether the prop is animated at all. */
|
|
619
|
+
const allKeyed = allProps.every(prop => (trackFor(target, prop)?.points ?? []).some(p => p.t === localT))
|
|
620
|
+
|
|
621
|
+
function handleKeyframeAll() {
|
|
622
|
+
let next = target
|
|
623
|
+
if (allKeyed) {
|
|
624
|
+
for (const prop of allProps) {
|
|
625
|
+
const points = trackFor(next, prop)?.points ?? []
|
|
626
|
+
// The distinction matters. `removeKeyframe` on a track's LAST point
|
|
627
|
+
// drops the track WITHOUT writing the sampled value into the static
|
|
628
|
+
// scalar, so the overlay would jump back to whatever stale
|
|
629
|
+
// `item.scale`/`item.rotation`/… was left behind when keyframing was
|
|
630
|
+
// first switched on. `disableKeyframing` writes that value first, so
|
|
631
|
+
// nothing moves. Multi-point tracks keep animating, so they only lose
|
|
632
|
+
// the one point.
|
|
633
|
+
next = points.length > 1
|
|
634
|
+
? removeKeyframe(next, prop, localT)
|
|
635
|
+
: disableKeyframing(next, prop, localT)
|
|
636
|
+
}
|
|
637
|
+
} else {
|
|
638
|
+
for (const prop of allProps) {
|
|
639
|
+
// `enableKeyframing` is a documented NO-OP on an already-keyframed
|
|
640
|
+
// prop, so it is safe to run across the whole list unconditionally; it
|
|
641
|
+
// only seeds a track for the ones that had none. `setKeyframe` then pins
|
|
642
|
+
// the CURRENT value at the playhead on every prop, animated or not.
|
|
643
|
+
// Values come from `sampled` (read off the original item), so nothing
|
|
644
|
+
// on screen moves and no write perturbs another's sample.
|
|
645
|
+
next = setKeyframe(enableKeyframing(next, prop, localT), prop, localT, sampled[prop])
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
onChange(next)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Position (offsetX + offsetY) keyed as a pair — CapCut shows one diamond for
|
|
652
|
+
// Position, so the row animates/toggles both axes together, with the same
|
|
653
|
+
// enable/disable rule the per-row diamonds use (handleToggle). Kept separate
|
|
654
|
+
// from handleKeyframeAll so that the all-props path (`transformProps(item)`,
|
|
655
|
+
// which varies with the uniform-scale lock) is untouched.
|
|
656
|
+
const positionProps: KeyframeProp[] = ['offsetX', 'offsetY']
|
|
657
|
+
const positionKeyed = positionProps.every(prop => hasKeyframes(target, prop))
|
|
658
|
+
|
|
659
|
+
function handlePositionToggle() {
|
|
660
|
+
let next = target
|
|
661
|
+
for (const prop of positionProps) {
|
|
662
|
+
next = positionKeyed ? disableKeyframing(next, prop, localT) : enableKeyframing(next, prop, localT)
|
|
663
|
+
}
|
|
664
|
+
onChange(next)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Scale is shown as a PERCENTAGE (CapCut: 1 => 100%). The box reports percent;
|
|
668
|
+
// the stored scalar stays a multiplier, so convert on the way in. The slider
|
|
669
|
+
// and stepper stay on the multiplier (the Slider below / handleStep). Takes
|
|
670
|
+
// the prop so the unlocked per-axis boxes convert through the same path as
|
|
671
|
+
// the locked one instead of a second copy of the `/ 100`. Unparseable and
|
|
672
|
+
// mid-typing values never reach here — NumberField swallows those.
|
|
673
|
+
function handleScalePercent(prop: 'scale' | 'scaleX' | 'scaleY', pct: number) {
|
|
674
|
+
preview(prop, pct / 100)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Move the item between uniform `scale` and per-axis `scaleX`/`scaleY`. One
|
|
679
|
+
* discrete edit either way — one undo entry, no separate commit.
|
|
680
|
+
*
|
|
681
|
+
* Both directions route their VALUE write through `writeProp`, so the panel's
|
|
682
|
+
* auto-keyframe rule applies here exactly as it does to typing in a box: a
|
|
683
|
+
* prop that is already animated gets a keyframe at the playhead rather than a
|
|
684
|
+
* silently-detaching static scalar.
|
|
685
|
+
*/
|
|
686
|
+
function handleUniformToggle() {
|
|
687
|
+
if (uniform) {
|
|
688
|
+
// ON -> OFF (unlocking). Seed BOTH axes from `sampled.scale` — the scale
|
|
689
|
+
// the overlay actually has at the playhead, animated value included, not
|
|
690
|
+
// the raw `item.scale` — so the instant the lock opens nothing moves.
|
|
691
|
+
// Once `scaleX`/`scaleY` exist they shadow `scale` entirely, which is
|
|
692
|
+
// precisely why they have to be seeded rather than left absent.
|
|
693
|
+
let next = writeProp(target, 'scaleX', localT, sampled.scale)
|
|
694
|
+
next = writeProp(next, 'scaleY', localT, sampled.scale)
|
|
695
|
+
onChange(next)
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
// OFF -> ON (locking). Two numbers have to collapse into one and X WINS.
|
|
699
|
+
// That is arbitrary — Y would have been just as defensible — but it is
|
|
700
|
+
// CHOSEN and stable, not accidental: the operator can read the surviving
|
|
701
|
+
// number off the Scale X box before clicking, and locking always means the
|
|
702
|
+
// same thing. Then both per-axis fields are cleared outright, tracks and
|
|
703
|
+
// all (see clearScaleAxis), because merely writing them equal would leave
|
|
704
|
+
// them shadowing `scale` and the row would have nothing to lock TO.
|
|
705
|
+
let next = writeProp(target, 'scale', localT, sampled.scaleX)
|
|
706
|
+
next = clearScaleAxis(next, 'scaleX')
|
|
707
|
+
next = clearScaleAxis(next, 'scaleY')
|
|
708
|
+
onChange(next)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function handleReset() {
|
|
712
|
+
let next = target
|
|
713
|
+
// Deliberately does NOT delete keyframe tracks. On a keyframed prop this
|
|
714
|
+
// drops a default-valued keyframe at the playhead — the same
|
|
715
|
+
// non-destructive rule every other control in this panel follows, and it
|
|
716
|
+
// keeps "reset" from silently discarding an animation the operator spent
|
|
717
|
+
// real time on. Clearing a track is the per-row diamond's job.
|
|
718
|
+
// Walks `allProps`, so an unlocked item resets `scaleX`/`scaleY` to 1 and
|
|
719
|
+
// leaves the shadowed `scale` alone. Reset does not re-lock: the lock is
|
|
720
|
+
// the operator's own choice about how this overlay is authored, not a
|
|
721
|
+
// transform value with a default.
|
|
722
|
+
for (const prop of allProps) next = writeProp(next, prop, localT, DEFAULTS[prop])
|
|
723
|
+
onChange(next)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const headerNav = navFor(allProps)
|
|
727
|
+
const positionNav = navFor(positionProps)
|
|
728
|
+
|
|
729
|
+
/** The `‹ ◇ ›` unit for one row. */
|
|
730
|
+
function rowNav(prop: KeyframeProp) {
|
|
731
|
+
const row = ROWS[prop]
|
|
732
|
+
const nav = navFor([prop])
|
|
733
|
+
const keyframed = hasKeyframes(target, prop)
|
|
734
|
+
// Per-property, per-kind: a clip animates position/scale/rotation but not
|
|
735
|
+
// opacity, because ffmpeg's `colorchannelmixer aa` takes a <double> and no
|
|
736
|
+
// expression. See canKeyframeProp for the full reason.
|
|
737
|
+
const allowed = canKeyframeProp(target, prop)
|
|
738
|
+
return (
|
|
739
|
+
<KeyframeNav
|
|
740
|
+
prevLabel={`Previous ${row.name} keyframe`}
|
|
741
|
+
nextLabel={`Next ${row.name} keyframe`}
|
|
742
|
+
diamondLabel={keyframed ? `Remove ${row.name} keyframe at playhead` : `Add ${row.name} keyframe at playhead`}
|
|
743
|
+
pressed={keyframed}
|
|
744
|
+
canPrev={nav.canPrev}
|
|
745
|
+
canNext={nav.canNext}
|
|
746
|
+
onPrev={nav.onPrev}
|
|
747
|
+
onNext={nav.onNext}
|
|
748
|
+
onDiamond={() => handleToggle(prop)}
|
|
749
|
+
diamondDisabled={!allowed}
|
|
750
|
+
diamondReason={allowed ? undefined
|
|
751
|
+
: `${row.name} cannot be animated on a video or image clip. The export has no way to vary it over time. Overlays can.`}
|
|
752
|
+
/>
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const alignButtons: { label: string; icon: ComponentType<{ size?: number }>; prop: 'offsetX' | 'offsetY'; edge: -1 | 0 | 1 }[] = [
|
|
757
|
+
{ label: 'Align left', icon: AlignStartVertical, prop: 'offsetX', edge: -1 },
|
|
758
|
+
{ label: 'Align center', icon: AlignCenterVertical, prop: 'offsetX', edge: 0 },
|
|
759
|
+
{ label: 'Align right', icon: AlignEndVertical, prop: 'offsetX', edge: 1 },
|
|
760
|
+
{ label: 'Align top', icon: AlignStartHorizontal, prop: 'offsetY', edge: -1 },
|
|
761
|
+
{ label: 'Align middle', icon: AlignCenterHorizontal, prop: 'offsetY', edge: 0 },
|
|
762
|
+
{ label: 'Align bottom', icon: AlignEndHorizontal, prop: 'offsetY', edge: 1 },
|
|
763
|
+
]
|
|
764
|
+
|
|
765
|
+
return (
|
|
766
|
+
<div className={SECTION_CLASS}>
|
|
767
|
+
{/* No section TITLE here — the CONTENT/TRANSFORM tab already names this
|
|
768
|
+
pane, so a second "Transform" heading was redundant. This slim bar now
|
|
769
|
+
only hosts the ALL-properties actions (reset-all + keyframe-all); the
|
|
770
|
+
muted "All" scopes them apart from each row's own per-property unit. */}
|
|
771
|
+
<div className="shrink-0 flex items-center gap-1 border-b border-[var(--editor-border)] px-2 py-1.5">
|
|
772
|
+
<span className="px-1 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--editor-text)]/40">
|
|
773
|
+
All
|
|
774
|
+
</span>
|
|
775
|
+
<div className="ml-auto flex items-center gap-1">
|
|
776
|
+
<IconButton label="Reset transform" title="Reset every transform property to its default" onClick={handleReset}>
|
|
777
|
+
<RotateCcw size={12} />
|
|
778
|
+
</IconButton>
|
|
779
|
+
<KeyframeNav
|
|
780
|
+
prevLabel="Previous keyframe"
|
|
781
|
+
nextLabel="Next keyframe"
|
|
782
|
+
diamondLabel="Keyframe all transform properties at playhead"
|
|
783
|
+
pressed={allKeyed}
|
|
784
|
+
canPrev={headerNav.canPrev}
|
|
785
|
+
canNext={headerNav.canNext}
|
|
786
|
+
onPrev={headerNav.onPrev}
|
|
787
|
+
onNext={headerNav.onNext}
|
|
788
|
+
onDiamond={handleKeyframeAll}
|
|
789
|
+
/>
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
|
|
793
|
+
<div className="flex flex-col gap-2 p-2">
|
|
794
|
+
{/* ── Scale ─────────────────────────────────────────────────────
|
|
795
|
+
LOCKED (the default, and every legacy overlay): one row (CapCut)
|
|
796
|
+
— slider + a PERCENTAGE value (1 => 100%) + stepper + keyframe
|
|
797
|
+
unit, all writing the uniform `scale`.
|
|
798
|
+
|
|
799
|
+
UNLOCKED: that row splits per axis into Scale X / Scale Y, each
|
|
800
|
+
writing its own prop and carrying its OWN keyframe unit (they
|
|
801
|
+
animate independently, so one shared diamond would be a lie).
|
|
802
|
+
Two rows rather than both boxes crammed into one: the Position
|
|
803
|
+
row already fills the 300px default rail with two boxes and a
|
|
804
|
+
SINGLE shared keyframe unit, and a second unit does not fit
|
|
805
|
+
beside them. Each row is otherwise the Rotate/Opacity row
|
|
806
|
+
verbatim — label, cell, keyframe unit pushed right. The slider
|
|
807
|
+
is dropped while unlocked; it drives one number and there are
|
|
808
|
+
two. */}
|
|
809
|
+
{uniform ? (
|
|
810
|
+
<div className="flex items-center gap-2">
|
|
811
|
+
<span className={ROW_LABEL_CLASS}>Scale</span>
|
|
812
|
+
<Slider
|
|
813
|
+
aria-label="Scale slider"
|
|
814
|
+
value={sampled.scale}
|
|
815
|
+
min={SCALE_SLIDER_MIN}
|
|
816
|
+
max={SCALE_SLIDER_MAX}
|
|
817
|
+
step={0.01}
|
|
818
|
+
onChange={v => preview('scale', v)}
|
|
819
|
+
// Zero-arg: the panel commits whatever it last previewed, so it
|
|
820
|
+
// has no use for the value the Slider offers.
|
|
821
|
+
onCommit={onCommit}
|
|
822
|
+
className="min-w-0 flex-1"
|
|
823
|
+
/>
|
|
824
|
+
<ScalePercentCell
|
|
825
|
+
row={ROWS.scale}
|
|
826
|
+
value={sampled.scale}
|
|
827
|
+
onPreview={pct => handleScalePercent('scale', pct)}
|
|
828
|
+
onCommit={onCommit}
|
|
829
|
+
onStep={d => handleStep(ROWS.scale, d)}
|
|
830
|
+
/>
|
|
831
|
+
{rowNav('scale')}
|
|
832
|
+
</div>
|
|
833
|
+
) : (
|
|
834
|
+
(['scaleX', 'scaleY'] as const).map(prop => (
|
|
835
|
+
<div key={prop} className="flex items-center gap-2">
|
|
836
|
+
<span className={ROW_LABEL_CLASS}>{ROWS[prop].name}</span>
|
|
837
|
+
<ScalePercentCell
|
|
838
|
+
row={ROWS[prop]}
|
|
839
|
+
value={sampled[prop]}
|
|
840
|
+
onPreview={pct => handleScalePercent(prop, pct)}
|
|
841
|
+
onCommit={onCommit}
|
|
842
|
+
onStep={d => handleStep(ROWS[prop], d)}
|
|
843
|
+
/>
|
|
844
|
+
<div className="ml-auto">{rowNav(prop)}</div>
|
|
845
|
+
</div>
|
|
846
|
+
))
|
|
847
|
+
)}
|
|
848
|
+
|
|
849
|
+
{/* ── Uniform scale ─────────────────────────────────────────────
|
|
850
|
+
Its own labelled toggle row (CapCut) instead of a bare link icon.
|
|
851
|
+
ON means the item scales through one uniform `scale`; turning it
|
|
852
|
+
off splits that into `scaleX`/`scaleY`, and back on collapses
|
|
853
|
+
them again — see handleUniformToggle for both tiebreaks. The
|
|
854
|
+
state is DERIVED from the item (isUniform), never held here, so
|
|
855
|
+
selecting a different overlay shows that overlay's answer.
|
|
856
|
+
`role="checkbox"` on a <button> is keyboard-operable natively
|
|
857
|
+
(Space/Enter both fire click), so it needs no key handler of its
|
|
858
|
+
own — only a visible focus ring. */}
|
|
859
|
+
<div className="flex items-center gap-2">
|
|
860
|
+
<span className="shrink-0 text-[11px] text-[var(--editor-text)]/55">Uniform scale</span>
|
|
861
|
+
<button
|
|
862
|
+
type="button"
|
|
863
|
+
role="checkbox"
|
|
864
|
+
aria-checked={uniform}
|
|
865
|
+
aria-label="Uniform scale"
|
|
866
|
+
title={uniform ? 'Width and height scale together' : 'Width and height scale independently'}
|
|
867
|
+
onClick={handleUniformToggle}
|
|
868
|
+
className={cn(
|
|
869
|
+
'ml-auto relative h-4 w-7 shrink-0 rounded-full outline-none transition-colors focus-visible:ring-1 focus-visible:ring-[var(--editor-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--editor-surface)]',
|
|
870
|
+
uniform ? 'bg-[var(--editor-accent)]' : 'bg-[var(--editor-border)]',
|
|
871
|
+
)}
|
|
872
|
+
>
|
|
873
|
+
<span
|
|
874
|
+
className={cn(
|
|
875
|
+
'absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all',
|
|
876
|
+
uniform ? 'right-0.5' : 'left-0.5',
|
|
877
|
+
)}
|
|
878
|
+
/>
|
|
879
|
+
</button>
|
|
880
|
+
</div>
|
|
881
|
+
|
|
882
|
+
{/* ── Position ───────────────────────────────────────────────────
|
|
883
|
+
X and Y with ONE keyframe unit for the pair on the right (CapCut),
|
|
884
|
+
not a diamond crammed in after each axis. */}
|
|
885
|
+
<div className="flex items-center gap-2">
|
|
886
|
+
<span className={ROW_LABEL_CLASS}>Position</span>
|
|
887
|
+
<NumberCell
|
|
888
|
+
row={ROWS.offsetX}
|
|
889
|
+
prefix="X"
|
|
890
|
+
value={sampled.offsetX}
|
|
891
|
+
onPreview={v => preview('offsetX', v)}
|
|
892
|
+
onCommit={onCommit}
|
|
893
|
+
onStep={d => handleStep(ROWS.offsetX, d)}
|
|
894
|
+
/>
|
|
895
|
+
<NumberCell
|
|
896
|
+
row={ROWS.offsetY}
|
|
897
|
+
prefix="Y"
|
|
898
|
+
value={sampled.offsetY}
|
|
899
|
+
onPreview={v => preview('offsetY', v)}
|
|
900
|
+
onCommit={onCommit}
|
|
901
|
+
onStep={d => handleStep(ROWS.offsetY, d)}
|
|
902
|
+
/>
|
|
903
|
+
<div className="ml-auto">
|
|
904
|
+
<KeyframeNav
|
|
905
|
+
prevLabel="Previous Position keyframe"
|
|
906
|
+
nextLabel="Next Position keyframe"
|
|
907
|
+
diamondLabel={positionKeyed ? 'Remove Position keyframe at playhead' : 'Add Position keyframe at playhead'}
|
|
908
|
+
pressed={positionKeyed}
|
|
909
|
+
canPrev={positionNav.canPrev}
|
|
910
|
+
canNext={positionNav.canNext}
|
|
911
|
+
onPrev={positionNav.onPrev}
|
|
912
|
+
onNext={positionNav.onNext}
|
|
913
|
+
onDiamond={handlePositionToggle}
|
|
914
|
+
/>
|
|
915
|
+
</div>
|
|
916
|
+
</div>
|
|
917
|
+
|
|
918
|
+
{/* ── Rotate ───────────────────────────────────────────────────── */}
|
|
919
|
+
<div className="flex items-center gap-2">
|
|
920
|
+
<span className={ROW_LABEL_CLASS}>Rotate</span>
|
|
921
|
+
<NumberCell
|
|
922
|
+
row={ROWS.rotation}
|
|
923
|
+
value={sampled.rotation}
|
|
924
|
+
onPreview={v => preview('rotation', v)}
|
|
925
|
+
onCommit={onCommit}
|
|
926
|
+
onStep={d => handleStep(ROWS.rotation, d)}
|
|
927
|
+
/>
|
|
928
|
+
<RotationDial
|
|
929
|
+
rotation={sampled.rotation}
|
|
930
|
+
onPreview={deg => preview('rotation', deg)}
|
|
931
|
+
onCommit={onCommit}
|
|
932
|
+
onChange={deg => commitDiscrete('rotation', deg)}
|
|
933
|
+
/>
|
|
934
|
+
<div className="ml-auto">{rowNav('rotation')}</div>
|
|
935
|
+
</div>
|
|
936
|
+
|
|
937
|
+
{/* ── Opacity ──────────────────────────────────────────────────── */}
|
|
938
|
+
<div className="flex items-center gap-2">
|
|
939
|
+
<span className={ROW_LABEL_CLASS}>Opacity</span>
|
|
940
|
+
<NumberCell
|
|
941
|
+
row={ROWS.opacity}
|
|
942
|
+
value={sampled.opacity}
|
|
943
|
+
onPreview={v => preview('opacity', v)}
|
|
944
|
+
onCommit={onCommit}
|
|
945
|
+
onStep={d => handleStep(ROWS.opacity, d)}
|
|
946
|
+
/>
|
|
947
|
+
<div className="ml-auto">{rowNav('opacity')}</div>
|
|
948
|
+
</div>
|
|
949
|
+
|
|
950
|
+
{/* ── Align ────────────────────────────────────────────────────── */}
|
|
951
|
+
<div className="flex items-center gap-2">
|
|
952
|
+
<span className={ROW_LABEL_CLASS}>Align</span>
|
|
953
|
+
{[alignButtons.slice(0, 3), alignButtons.slice(3)].map((group, i) => (
|
|
954
|
+
<div key={i} className="flex items-center gap-0.5">
|
|
955
|
+
{group.map(({ label, icon: Icon, prop, edge }) => (
|
|
956
|
+
<IconButton
|
|
957
|
+
key={label}
|
|
958
|
+
label={label}
|
|
959
|
+
// The CURRENT sampled scale, not the raw scalar, so
|
|
960
|
+
// aligning mid-animation snaps to the edge the overlay
|
|
961
|
+
// actually has at the playhead — and the scale of the axis
|
|
962
|
+
// being aligned, since a non-uniform overlay's left edge is
|
|
963
|
+
// set by its WIDTH and its top edge by its HEIGHT. On a
|
|
964
|
+
// uniform item both sample back to `scale`, so legacy
|
|
965
|
+
// overlays align exactly as before.
|
|
966
|
+
onClick={() => commitDiscrete(prop, alignedOffset(prop === 'offsetX' ? sampled.scaleX : sampled.scaleY, edge))}
|
|
967
|
+
>
|
|
968
|
+
<Icon size={12} />
|
|
969
|
+
</IconButton>
|
|
970
|
+
))}
|
|
971
|
+
</div>
|
|
972
|
+
))}
|
|
973
|
+
</div>
|
|
974
|
+
</div>
|
|
975
|
+
</div>
|
|
976
|
+
)
|
|
977
|
+
}
|