@bycrux/editor 0.11.2 → 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 +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- 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 +601 -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 +137 -0
- 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 +2229 -243
- 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/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -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,384 @@
|
|
|
1
|
+
import type { EasingName, Keyframe, KeyframeProp, KeyframeTrack, VisualItem } from '../schema'
|
|
2
|
+
import { geometryAt, normalizeTrack } from '@bycrux/timeline-core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* keyframeOps — the shared, DOM-free keyframe-mutation surface for SP9b.
|
|
6
|
+
*
|
|
7
|
+
* Every export below is a pure function shaped `(item, ...) => VisualItem`:
|
|
8
|
+
* none of them touch React, the DOM, or the app store. Callers (an overlay
|
|
9
|
+
* inspector panel and a canvas timeline keyframe strip, in later phases) are
|
|
10
|
+
* expected to feed the returned item straight into a `sync.mutate` project
|
|
11
|
+
* update. The input `item`, its `keyframes` array, and its point objects are
|
|
12
|
+
* never mutated — every write returns fresh objects/arrays instead.
|
|
13
|
+
*
|
|
14
|
+
* The invariant this module exists to protect: every `KeyframeTrack` it
|
|
15
|
+
* writes has `points` ascending by `t` with no duplicate `t` — the invariant
|
|
16
|
+
* `@bycrux/timeline-core`'s `sampleTrack` assumes and does not itself enforce
|
|
17
|
+
* (see that package's `src/curves.js` module header). Every mutating export
|
|
18
|
+
* below builds its raw (possibly out-of-order, possibly duplicate-`t`) points
|
|
19
|
+
* array and pipes it through `normalizeTrack` before it is ever installed on
|
|
20
|
+
* the returned item — `withTrack`, at the bottom of the "writing" section, is
|
|
21
|
+
* the single place a track is actually written, so that holds by
|
|
22
|
+
* construction rather than by every export remembering to do it.
|
|
23
|
+
*
|
|
24
|
+
* No easing/interpolation math lives here. Curve evaluation stays in
|
|
25
|
+
* `@bycrux/timeline-core` (`sampleTrack`, `geometryAt`) so the preview and
|
|
26
|
+
* the render bake cannot drift from each other or from this module.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* THE single gate on which items support keyframing. Every call site that used
|
|
31
|
+
* to spell `item.type === 'overlay'` inline routes through here instead, so
|
|
32
|
+
* the set of keyframeable kinds is defined in exactly one place.
|
|
33
|
+
*
|
|
34
|
+
* VIDEO, IMAGE AND OVERLAY, since SP9d. It was overlay-only for a real render
|
|
35
|
+
* reason, not a UI preference: the ffmpeg composite emitted ONE static box per
|
|
36
|
+
* segment and had no per-frame hook, while overlays escaped that only because
|
|
37
|
+
* they are captured frame-by-frame in a browser. That changed when
|
|
38
|
+
* `encode-segment.js` learned to compile a curve into a time-varying ffmpeg
|
|
39
|
+
* expression (`animatedGeometry`), so a clip's position, scale and rotation now
|
|
40
|
+
* animate in the export exactly as they do in the preview.
|
|
41
|
+
*
|
|
42
|
+
* Keyframeability is now PER PROPERTY PER KIND, though — a clip can animate
|
|
43
|
+
* position but not opacity — so an item-level yes/no is no longer the whole
|
|
44
|
+
* answer. Use {@link canKeyframeProp} wherever a specific property is in hand;
|
|
45
|
+
* this predicate answers only "can ANY property on this item be keyframed".
|
|
46
|
+
*
|
|
47
|
+
* A type predicate, not a plain `boolean`: call sites used to spell
|
|
48
|
+
* `!item || item.type !== 'overlay'`, which narrowed `item`'s nullability
|
|
49
|
+
* through the guard for free. Returning `item is VisualItem` keeps that
|
|
50
|
+
* narrowing available through this one call instead. It is deliberately
|
|
51
|
+
* `VisualItem`, not some overlay-only subtype — `VisualItem` is a monolithic
|
|
52
|
+
* interface with `type` as a plain field rather than a discriminant, so
|
|
53
|
+
* there is no narrower shape to assert; this is the strongest claim TS can
|
|
54
|
+
* check.
|
|
55
|
+
*/
|
|
56
|
+
export function canKeyframe(item: VisualItem | null | undefined): item is VisualItem {
|
|
57
|
+
return !!item && (item.type === 'overlay' || item.type === 'video' || item.type === 'image')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether ONE property on ONE item can be keyframed.
|
|
62
|
+
*
|
|
63
|
+
* Overlays: everything. Clips (video/image): everything EXCEPT `opacity`.
|
|
64
|
+
*
|
|
65
|
+
* The opacity exclusion is a hard limit of the render, not a scope decision.
|
|
66
|
+
* A clip's transform reaches ffmpeg as a filter expression, and ffmpeg happily
|
|
67
|
+
* evaluates expressions for `overlay`'s x/y, `scale`'s w/h and `rotate`'s
|
|
68
|
+
* angle. Its alpha control does not play along: `colorchannelmixer` declares
|
|
69
|
+
* `aa` as a `<double>`, which accepts a literal number and nothing else — no
|
|
70
|
+
* expression, at any evaluation mode. (The `T` flag ffmpeg prints beside it is
|
|
71
|
+
* `AV_OPT_FLAG_RUNTIME_PARAM`, i.e. settable via `sendcmd`/`zmq`; it is not
|
|
72
|
+
* expression support, and it has been misread as such before.) There is
|
|
73
|
+
* therefore no way to fade a clip through the ffmpeg path at all.
|
|
74
|
+
*
|
|
75
|
+
* Overlays are exempt because they never touch that filter: they are baked
|
|
76
|
+
* frame-by-frame in a browser, where opacity is just another CSS value.
|
|
77
|
+
*
|
|
78
|
+
* Closing this gap needs the per-frame browser bake extended to video — decode
|
|
79
|
+
* every frame of the animated span and composite it the way overlays already
|
|
80
|
+
* are. That was measured at 14-33x the expression path's render time and is
|
|
81
|
+
* explicitly out of scope; see docs/RENDER.md.
|
|
82
|
+
*/
|
|
83
|
+
export function canKeyframeProp(item: VisualItem | null | undefined, prop: KeyframeProp): boolean {
|
|
84
|
+
if (!canKeyframe(item)) return false
|
|
85
|
+
if (item.type === 'overlay') return true
|
|
86
|
+
return prop !== 'opacity'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Reading ──────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** The track for `prop` on `item`, or `undefined` if the item isn't
|
|
92
|
+
* keyframed on that prop at all. */
|
|
93
|
+
export function trackFor(item: VisualItem, prop: KeyframeProp): KeyframeTrack | undefined {
|
|
94
|
+
return item.keyframes?.find(track => track.prop === prop)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True only when `prop` has a track AND that track has at least one point.
|
|
99
|
+
* An empty track never lingers in a well-formed item (see `withTrack`), but
|
|
100
|
+
* this checks the point count anyway rather than assuming that invariant
|
|
101
|
+
* holds for every possible caller or hand-edited project.json.
|
|
102
|
+
*/
|
|
103
|
+
export function hasKeyframes(item: VisualItem, prop: KeyframeProp): boolean {
|
|
104
|
+
const track = trackFor(item, prop)
|
|
105
|
+
return !!track && track.points.length > 0
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** True when `item` has ANY non-empty keyframe track, on any prop. */
|
|
109
|
+
export function isKeyframed(item: VisualItem): boolean {
|
|
110
|
+
return (item.keyframes ?? []).some(track => track.points.length > 0)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether `item` scales UNIFORMLY — i.e. carries no per-axis scale AT ALL,
|
|
115
|
+
* neither a static `scaleX`/`scaleY` scalar nor a keyframe track for either.
|
|
116
|
+
*
|
|
117
|
+
* ABSENCE is the test, deliberately, and not `scaleX === scaleY`: an overlay
|
|
118
|
+
* the operator unlocked on purpose and happens to have left at 120%/120% is
|
|
119
|
+
* authored per-axis, and an equality test would silently re-lock it the moment
|
|
120
|
+
* the two numbers met.
|
|
121
|
+
*/
|
|
122
|
+
export function isUniformScale(item: VisualItem): boolean {
|
|
123
|
+
return (
|
|
124
|
+
item.scaleX === undefined && item.scaleY === undefined &&
|
|
125
|
+
!hasKeyframes(item, 'scaleX') && !hasKeyframes(item, 'scaleY')
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The two orders {@link transformProps} chooses between. Position first,
|
|
130
|
+
* scale, then rotation and opacity — the order the inspector header's
|
|
131
|
+
* all-props actions walk them, kept identical for both shapes. */
|
|
132
|
+
const UNIFORM_TRANSFORM_PROPS: readonly KeyframeProp[] = ['offsetX', 'offsetY', 'scale', 'rotation', 'opacity']
|
|
133
|
+
const PER_AXIS_TRANSFORM_PROPS: readonly KeyframeProp[] = ['offsetX', 'offsetY', 'scaleX', 'scaleY', 'rotation', 'opacity']
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The transform props that are AUTHORITATIVE for `item` — the set that any
|
|
137
|
+
* "do this to EVERY transform prop" action must walk, and the whole reason
|
|
138
|
+
* {@link isUniformScale} exists.
|
|
139
|
+
*
|
|
140
|
+
* Never a flat list of all seven, and never one fixed list of five. The scale
|
|
141
|
+
* props form a fallback chain — `sampleTrack(scaleX) ?? item.scaleX ??
|
|
142
|
+
* <the resolved scale>` (see `geometry.js`'s non-uniform section) — so a
|
|
143
|
+
* per-axis value SHADOWS the uniform one, and getting this set wrong breaks a
|
|
144
|
+
* keyframe-everything action in one of two symmetric ways:
|
|
145
|
+
*
|
|
146
|
+
* - Handing `scaleX`/`scaleY` to a UNIFORM item seeds one-point (i.e.
|
|
147
|
+
* constant) per-axis tracks. Those immediately shadow the `scale` track,
|
|
148
|
+
* and the overlay's uniform zoom silently stops happening — nothing on
|
|
149
|
+
* screen says why, and the damage is invisible until the operator scrubs.
|
|
150
|
+
* - Handing `scale` to a PER-AXIS item writes a prop that `scaleX`/`scaleY`
|
|
151
|
+
* already shadow, so the gesture appears to do nothing at all.
|
|
152
|
+
*
|
|
153
|
+
* Both the inspector's header actions and the canvas timeline's
|
|
154
|
+
* double-click-to-key gesture read this, so the rule is defined once. It used
|
|
155
|
+
* to be a hand-maintained constant in each of them; two copies of a rule whose
|
|
156
|
+
* failure mode is a silent frozen animation is exactly the kind of thing that
|
|
157
|
+
* drifts. Do NOT reintroduce a local copy.
|
|
158
|
+
*
|
|
159
|
+
* The result is ALSO filtered by {@link canKeyframeProp}, which is what keeps a
|
|
160
|
+
* clip's un-animatable `opacity` out of every "do this to every transform prop"
|
|
161
|
+
* action. That matters in both directions and both are easy to get wrong:
|
|
162
|
+
* double-clicking a video would otherwise write an opacity track the renderer
|
|
163
|
+
* silently ignores, and the inspector's header diamond — which lights only when
|
|
164
|
+
* EVERY prop in this list is keyed at the playhead — could then never light on a
|
|
165
|
+
* clip at all, because the one prop it waits for can never be keyed.
|
|
166
|
+
*/
|
|
167
|
+
export function transformProps(item: VisualItem): readonly KeyframeProp[] {
|
|
168
|
+
const base = isUniformScale(item) ? UNIFORM_TRANSFORM_PROPS : PER_AXIS_TRANSFORM_PROPS
|
|
169
|
+
return base.filter(prop => canKeyframeProp(item, prop))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `prop`'s value at item-relative `localT`: the sampled curve when `prop` is
|
|
174
|
+
* keyframed, else the item's static scalar, else the prop's default. This
|
|
175
|
+
* delegates to {@link geometryAt} — the SAME function the preview and the
|
|
176
|
+
* render bake sample from — rather than re-deriving defaults or calling
|
|
177
|
+
* `sampleTrack` directly, so the defaults (scale 1, offsetX/offsetY/rotation
|
|
178
|
+
* 0, opacity 1) live in exactly one place and cannot drift from what
|
|
179
|
+
* actually gets painted. `item.type` is passed through as the `kind` rather
|
|
180
|
+
* than a hardcoded `'overlay'`: `geometryAt`'s `kind` only selects `fit`,
|
|
181
|
+
* which isn't a keyframeable prop, so every one of the five reads is
|
|
182
|
+
* identical either way — but this way the function never lies about what
|
|
183
|
+
* kind of item it's reading.
|
|
184
|
+
*/
|
|
185
|
+
export function valueAt(item: VisualItem, prop: KeyframeProp, localT: number): number {
|
|
186
|
+
return geometryAt(item, item.type, localT)[prop]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Writing ──────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Install `track` as the sole track for `prop` on a NEW item, or remove
|
|
193
|
+
* `prop`'s track entirely when `track` is undefined or empty. This is the
|
|
194
|
+
* single place `item.keyframes` is ever written, so the invariants every
|
|
195
|
+
* mutating export below depends on hold by construction:
|
|
196
|
+
* - removing the last point of a track removes the track;
|
|
197
|
+
* - removing the last track removes `item.keyframes` itself (`undefined`,
|
|
198
|
+
* never a lingering `[]` — downstream code treats "no keyframes" as the
|
|
199
|
+
* static path, and `[]` must behave identically to absent).
|
|
200
|
+
*/
|
|
201
|
+
function withTrack(item: VisualItem, prop: KeyframeProp, track: KeyframeTrack | undefined): VisualItem {
|
|
202
|
+
const existing = item.keyframes ?? []
|
|
203
|
+
const idx = existing.findIndex(t => t.prop === prop)
|
|
204
|
+
|
|
205
|
+
if (!track || track.points.length === 0) {
|
|
206
|
+
if (idx < 0) return item // prop already had no track — no-op
|
|
207
|
+
const others = existing.filter(t => t.prop !== prop)
|
|
208
|
+
if (others.length === 0) {
|
|
209
|
+
const next = { ...item }
|
|
210
|
+
delete next.keyframes
|
|
211
|
+
return next
|
|
212
|
+
}
|
|
213
|
+
return { ...item, keyframes: others }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const next = idx < 0 ? [...existing, track] : existing.map((t, i) => (i === idx ? track : t))
|
|
217
|
+
return { ...item, keyframes: next }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Write `value` into `prop`'s own static scalar field on a new item. Used
|
|
221
|
+
* only by `disableKeyframing`, once keyframing is turned off. An exhaustive
|
|
222
|
+
* switch (no `default`) rather than a computed property, so adding a new
|
|
223
|
+
* `KeyframeProp` without a case here is a compile error, not a silent gap. */
|
|
224
|
+
function withStaticValue(item: VisualItem, prop: KeyframeProp, value: number): VisualItem {
|
|
225
|
+
switch (prop) {
|
|
226
|
+
case 'offsetX': return { ...item, offsetX: value }
|
|
227
|
+
case 'offsetY': return { ...item, offsetY: value }
|
|
228
|
+
case 'scale': return { ...item, scale: value }
|
|
229
|
+
case 'scaleX': return { ...item, scaleX: value }
|
|
230
|
+
case 'scaleY': return { ...item, scaleY: value }
|
|
231
|
+
case 'rotation': return { ...item, rotation: value }
|
|
232
|
+
case 'opacity': return { ...item, opacity: value }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Add or replace the keyframe at `t` on `prop`'s track, creating the track
|
|
238
|
+
* if `item` isn't keyframed on `prop` yet. Replacing an existing point at
|
|
239
|
+
* `t` preserves its `easing` unless a new one is passed. Non-finite `t` or
|
|
240
|
+
* `value` are ignored — `item` is returned unchanged rather than writing a
|
|
241
|
+
* malformed point.
|
|
242
|
+
*/
|
|
243
|
+
export function setKeyframe(
|
|
244
|
+
item: VisualItem,
|
|
245
|
+
prop: KeyframeProp,
|
|
246
|
+
t: number,
|
|
247
|
+
value: number,
|
|
248
|
+
easing?: EasingName,
|
|
249
|
+
): VisualItem {
|
|
250
|
+
if (!Number.isFinite(t) || !Number.isFinite(value)) return item
|
|
251
|
+
|
|
252
|
+
const existing = trackFor(item, prop)
|
|
253
|
+
const existingPoint = existing?.points.find(p => p.t === t)
|
|
254
|
+
const resolvedEasing = easing ?? existingPoint?.easing
|
|
255
|
+
const point: Keyframe = resolvedEasing === undefined ? { t, value } : { t, value, easing: resolvedEasing }
|
|
256
|
+
|
|
257
|
+
// Appended, not spliced in place: normalizeTrack's stable sort + last-wins
|
|
258
|
+
// de-duplication is what actually resolves a collision at `t`, so the new
|
|
259
|
+
// point only has to be LAST in authoring order among any duplicates.
|
|
260
|
+
const rawPoints = existing ? [...existing.points, point] : [point]
|
|
261
|
+
return withTrack(item, prop, normalizeTrack({ prop, points: rawPoints }))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Remove the keyframe at `t` on `prop`'s track. Removing the last point
|
|
266
|
+
* removes the whole track; removing the last track removes `item.keyframes`
|
|
267
|
+
* entirely (see `withTrack`).
|
|
268
|
+
*/
|
|
269
|
+
export function removeKeyframe(item: VisualItem, prop: KeyframeProp, t: number): VisualItem {
|
|
270
|
+
const track = trackFor(item, prop)
|
|
271
|
+
if (!track) return item
|
|
272
|
+
|
|
273
|
+
const points = track.points.filter(p => p.t !== t)
|
|
274
|
+
if (points.length === track.points.length) return item // t wasn't present — no-op
|
|
275
|
+
if (points.length === 0) return withTrack(item, prop, undefined)
|
|
276
|
+
|
|
277
|
+
return withTrack(item, prop, normalizeTrack({ prop, points }))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Remove every keyframe sitting at `t`, across all props — the whole diamond
|
|
282
|
+
* the operator sees, since one diamond on the strip is the UNION of every prop
|
|
283
|
+
* keyed at that instant (`keyframeUnionTimes`).
|
|
284
|
+
*
|
|
285
|
+
* The last-point branch is the reason this exists rather than callers looping
|
|
286
|
+
* `removeKeyframe`. `removeKeyframe` on a track's ONLY point drops the track
|
|
287
|
+
* without writing the sampled value into the item's static scalar, so the
|
|
288
|
+
* overlay snaps back to whatever stale value was sitting there from before
|
|
289
|
+
* keyframing was switched on. `disableKeyframing` samples the curve FIRST and
|
|
290
|
+
* writes it, so nothing moves. Every removal path must take that branch, or
|
|
291
|
+
* the two disagree — which is exactly what happened between the canvas
|
|
292
|
+
* right-click menu and the properties panel before this helper existed.
|
|
293
|
+
*
|
|
294
|
+
* Returns the SAME item when no prop has a point at `t`, so callers can use
|
|
295
|
+
* reference equality to skip a no-op commit.
|
|
296
|
+
*/
|
|
297
|
+
export function removeKeyframesAt(item: VisualItem, t: number): VisualItem {
|
|
298
|
+
if (!Number.isFinite(t)) return item
|
|
299
|
+
|
|
300
|
+
const props = (item.keyframes ?? [])
|
|
301
|
+
.filter(track => track.points.some(p => p.t === t))
|
|
302
|
+
.map(track => track.prop)
|
|
303
|
+
if (props.length === 0) return item
|
|
304
|
+
|
|
305
|
+
let next = item
|
|
306
|
+
for (const prop of props) {
|
|
307
|
+
const points = trackFor(next, prop)?.points ?? []
|
|
308
|
+
next = points.length > 1
|
|
309
|
+
? removeKeyframe(next, prop, t)
|
|
310
|
+
: disableKeyframing(next, prop, t)
|
|
311
|
+
}
|
|
312
|
+
return next
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Retime the keyframe at `fromT` to `toT`, preserving its value and easing.
|
|
317
|
+
* If `toT` collides with an existing keyframe, the MOVED one wins: it is
|
|
318
|
+
* appended after the rest of the points before normalizing, and
|
|
319
|
+
* `normalizeTrack`'s last-wins de-duplication (stable sort, so the later
|
|
320
|
+
* authoring-order entry survives a tie at the same `t`) always keeps the
|
|
321
|
+
* moved point in that case. Non-finite `fromT`/`toT` are ignored.
|
|
322
|
+
*/
|
|
323
|
+
export function moveKeyframe(item: VisualItem, prop: KeyframeProp, fromT: number, toT: number): VisualItem {
|
|
324
|
+
if (!Number.isFinite(fromT) || !Number.isFinite(toT)) return item
|
|
325
|
+
|
|
326
|
+
const track = trackFor(item, prop)
|
|
327
|
+
const point = track?.points.find(p => p.t === fromT)
|
|
328
|
+
if (!track || !point) return item
|
|
329
|
+
|
|
330
|
+
const moved: Keyframe = { ...point, t: toT }
|
|
331
|
+
const rest = track.points.filter(p => p.t !== fromT)
|
|
332
|
+
return withTrack(item, prop, normalizeTrack({ prop, points: [...rest, moved] }))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Set the OUTGOING easing (see `Keyframe.easing`'s doc comment) on the
|
|
336
|
+
* keyframe at `t`. No-op if `prop` has no track or no point at `t`. */
|
|
337
|
+
export function setKeyframeEasing(item: VisualItem, prop: KeyframeProp, t: number, easing: EasingName): VisualItem {
|
|
338
|
+
const track = trackFor(item, prop)
|
|
339
|
+
const point = track?.points.find(p => p.t === t)
|
|
340
|
+
if (!track || !point) return item
|
|
341
|
+
|
|
342
|
+
const points = track.points.map(p => (p.t === t ? { ...p, easing } : p))
|
|
343
|
+
return withTrack(item, prop, normalizeTrack({ prop, points }))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Turn keyframing ON for `prop`: seed a single keyframe at `atT` whose value
|
|
348
|
+
* is the item's CURRENT value for that prop (via {@link valueAt}), so
|
|
349
|
+
* switching keyframing on never moves the overlay.
|
|
350
|
+
*
|
|
351
|
+
* NO-OP, by construction, when `prop` already has keyframes
|
|
352
|
+
* (`hasKeyframes(item, prop)`): "turn this on" applied to something already
|
|
353
|
+
* on must never destroy the operator's existing animation. A caller with a
|
|
354
|
+
* genuinely destructive intent — discard the current track and start over —
|
|
355
|
+
* expresses that explicitly as `disableKeyframing` followed by
|
|
356
|
+
* `enableKeyframing`, two calls, not a single one that quietly does both. Do
|
|
357
|
+
* NOT "simplify" this back into an unconditional reset: a diamond-toggle UI,
|
|
358
|
+
* a defensive re-render, or a future "enable all props" action can all call
|
|
359
|
+
* this on an already-keyframed prop, and silently replacing a multi-point
|
|
360
|
+
* curve with one seeded point is invisible data loss until the operator
|
|
361
|
+
* scrubs. Non-finite `atT` is also ignored.
|
|
362
|
+
*/
|
|
363
|
+
export function enableKeyframing(item: VisualItem, prop: KeyframeProp, atT: number): VisualItem {
|
|
364
|
+
if (!Number.isFinite(atT)) return item
|
|
365
|
+
if (hasKeyframes(item, prop)) return item
|
|
366
|
+
|
|
367
|
+
const value = valueAt(item, prop, atT)
|
|
368
|
+
return withTrack(item, prop, normalizeTrack({ prop, points: [{ t: atT, value }] }))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Turn keyframing OFF for `prop`: remove its track entirely and write the
|
|
373
|
+
* value the curve held at `atT` into the item's static scalar, so the
|
|
374
|
+
* overlay does not jump the instant keyframing is switched off (the
|
|
375
|
+
* CapCut-style behaviour this is modelled on). The value is read via
|
|
376
|
+
* {@link valueAt} BEFORE the track is removed — `valueAt` needs the track
|
|
377
|
+
* still in place to sample it. Non-finite `atT` is ignored.
|
|
378
|
+
*/
|
|
379
|
+
export function disableKeyframing(item: VisualItem, prop: KeyframeProp, atT: number): VisualItem {
|
|
380
|
+
if (!Number.isFinite(atT)) return item
|
|
381
|
+
|
|
382
|
+
const value = valueAt(item, prop, atT)
|
|
383
|
+
return withStaticValue(withTrack(item, prop, undefined), prop, value)
|
|
384
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The editor's ONE keydown registry — replaces the racing `document`-level
|
|
5
|
+
* listeners that used to live independently in VideoEditor (split, undo/
|
|
6
|
+
* redo) and Timeline (arrows, delete, enter, escape), each with its own
|
|
7
|
+
* slightly different typing-surface guard. `useKeymap` mounts exactly one
|
|
8
|
+
* `document` keydown listener per call site and evaluates bindings in order;
|
|
9
|
+
* the first whose `matches` + `guard` both pass wins.
|
|
10
|
+
*
|
|
11
|
+
* Space is explicitly OUT OF SCOPE — it stays owned by the playback hooks
|
|
12
|
+
* (`useVideoPlayback`/`useEnginePlayback`, both legacy and engine paths) so
|
|
13
|
+
* this registry can never race them. See the hard-coded check in
|
|
14
|
+
* `useKeymap` below.
|
|
15
|
+
*
|
|
16
|
+
* There are two call sites, mirroring where their bindings' dependencies
|
|
17
|
+
* actually live (see T9's report for the full reasoning):
|
|
18
|
+
* - Timeline owns arrows/delete/enter/escape — it mounts its own
|
|
19
|
+
* `useKeymap` with those bindings, in BOTH the pending and review
|
|
20
|
+
* surfaces (exactly where its old internal listener ran).
|
|
21
|
+
* - VideoEditor's ReviewSurface owns split/undo/redo plus every new T9
|
|
22
|
+
* binding (J/K/L shuttle, ripple-delete, command palette) — the
|
|
23
|
+
* editing-only bindings that only ever lived in the review surface.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** INPUT / TEXTAREA / any contentEditable host — every migrated binding's
|
|
27
|
+
* typing-surface guard, ported verbatim from the handlers it replaces.
|
|
28
|
+
* `!!(...)` coerces to a real boolean: `el.isContentEditable` is a plain
|
|
29
|
+
* DOM boolean in every real browser, but partial event objects in tests
|
|
30
|
+
* (and jsdom's incomplete `isContentEditable` support) can leave it
|
|
31
|
+
* `undefined` — this keeps the guard's return type honest either way. */
|
|
32
|
+
export function isTypingTarget(e: KeyboardEvent): boolean {
|
|
33
|
+
const el = e.target as HTMLElement
|
|
34
|
+
return !!(el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface KeyBinding {
|
|
38
|
+
id: string
|
|
39
|
+
/** Palette label (also the tooltip/description). */
|
|
40
|
+
description: string
|
|
41
|
+
/** Display-only shortcut hint, e.g. ['⌘', 'Z']. Not used for matching. */
|
|
42
|
+
keyHint?: string[]
|
|
43
|
+
/** Whether this keydown event should fire the binding. */
|
|
44
|
+
matches: (e: KeyboardEvent) => boolean
|
|
45
|
+
/** Extra guard beyond the shared base guard (typing surface + modal-open),
|
|
46
|
+
* e.g. "only when there's a selection" or "only when totalDuration > 0". */
|
|
47
|
+
guard?: () => boolean
|
|
48
|
+
/** Defaults to true. */
|
|
49
|
+
preventDefault?: boolean
|
|
50
|
+
action: (e: KeyboardEvent) => void
|
|
51
|
+
/** Exclude from the command palette — context-only bindings (arrow frame-
|
|
52
|
+
* step, Enter/Escape marker placement) aren't "commands" you run once. */
|
|
53
|
+
paletteHidden?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UseKeymapOptions {
|
|
57
|
+
/** True while any dialog/palette is open — suppresses every binding. */
|
|
58
|
+
modalOpen?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mounts one `document` keydown listener for the given bindings for the
|
|
63
|
+
* lifetime of the component. Bindings and options are read through refs so
|
|
64
|
+
* the listener's identity never churns — callers can pass fresh closures
|
|
65
|
+
* every render without re-registering.
|
|
66
|
+
*/
|
|
67
|
+
export function useKeymap(bindings: KeyBinding[], options: UseKeymapOptions = {}) {
|
|
68
|
+
const bindingsRef = useRef(bindings)
|
|
69
|
+
bindingsRef.current = bindings
|
|
70
|
+
const optionsRef = useRef(options)
|
|
71
|
+
optionsRef.current = options
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
75
|
+
// Never owned by the keymap — see file header.
|
|
76
|
+
if (e.code === 'Space' || e.key === ' ') return
|
|
77
|
+
if (isTypingTarget(e)) return
|
|
78
|
+
if (optionsRef.current.modalOpen) return
|
|
79
|
+
|
|
80
|
+
for (const binding of bindingsRef.current) {
|
|
81
|
+
if (!binding.matches(e)) continue
|
|
82
|
+
if (binding.guard && !binding.guard()) continue
|
|
83
|
+
if (binding.preventDefault !== false) e.preventDefault()
|
|
84
|
+
binding.action(e)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
document.addEventListener('keydown', onKeyDown)
|
|
89
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
90
|
+
}, [])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Combo matchers — small helpers so bindings read declaratively ──────────
|
|
94
|
+
|
|
95
|
+
const mod = (e: KeyboardEvent) => !!(e.metaKey || e.ctrlKey)
|
|
96
|
+
|
|
97
|
+
/** Bare letter key, no modifier check — reproduces the original S-to-split
|
|
98
|
+
* handler's behavior verbatim (it only ever checked `e.key`). */
|
|
99
|
+
export function matchesKey(key: string) {
|
|
100
|
+
const lower = key.toLowerCase()
|
|
101
|
+
return (e: KeyboardEvent) => e.key.toLowerCase() === lower
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Letter key with NO modifier held (mod/alt) — used for the new J/K/L
|
|
105
|
+
* shuttle bindings so they don't fire alongside Cmd/Ctrl/Alt combos. */
|
|
106
|
+
export function matchesPlainKey(key: string) {
|
|
107
|
+
const lower = key.toLowerCase()
|
|
108
|
+
return (e: KeyboardEvent) => e.key.toLowerCase() === lower && !mod(e) && !e.altKey
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function matchesUndo(e: KeyboardEvent): boolean {
|
|
112
|
+
return mod(e) && e.key.toLowerCase() === 'z' && !e.shiftKey
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function matchesRedo(e: KeyboardEvent): boolean {
|
|
116
|
+
return mod(e) && ((e.key.toLowerCase() === 'z' && e.shiftKey) || e.key.toLowerCase() === 'y')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function matchesModKey(key: string) {
|
|
120
|
+
const lower = key.toLowerCase()
|
|
121
|
+
return (e: KeyboardEvent) => mod(e) && e.key.toLowerCase() === lower
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Mod+Alt+key, e.g. Cmd+Opt+V for paste-attributes. NOTE: `matchesModKey`
|
|
125
|
+
* does NOT exclude `altKey`, so a mod+alt chord matches BOTH this and the
|
|
126
|
+
* bare mod+key binding for the same letter — whichever binding is
|
|
127
|
+
* registered FIRST in a `useKeymap` array wins (first match wins), so a
|
|
128
|
+
* mod+alt binding must always be listed before its mod-only sibling. */
|
|
129
|
+
export function matchesModAltKey(key: string) {
|
|
130
|
+
const lower = key.toLowerCase()
|
|
131
|
+
return (e: KeyboardEvent) => mod(e) && e.altKey && e.key.toLowerCase() === lower
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Delete/Backspace WITHOUT Shift — reserves Shift+Delete for ripple-delete. */
|
|
135
|
+
export function matchesDelete(e: KeyboardEvent): boolean {
|
|
136
|
+
return (e.key === 'Delete' || e.key === 'Backspace') && !e.shiftKey
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function matchesShiftDelete(e: KeyboardEvent): boolean {
|
|
140
|
+
return (e.key === 'Delete' || e.key === 'Backspace') && e.shiftKey
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const matchesEscape = matchesKey('Escape')
|
|
144
|
+
export const matchesEnter = matchesKey('Enter')
|
|
145
|
+
export const matchesArrowLeft = matchesKey('ArrowLeft')
|
|
146
|
+
export const matchesArrowRight = matchesKey('ArrowRight')
|