@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,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor's branded loading indicator: a miniature of the Montaj mark — a
|
|
3
|
+
* film clapperboard (slate + play triangle) whose striped clapper claps open and
|
|
4
|
+
* shut. Used for every in-editor loading state (cover/frame sampling, render
|
|
5
|
+
* progress, overlay/schema fetches, engine warmup) so "loading" reads
|
|
6
|
+
* consistently and on-brand. NOT for streaming agent-status text (that keeps its
|
|
7
|
+
* own log/pending treatment).
|
|
8
|
+
*
|
|
9
|
+
* The slate uses the theme accent (with a literal fallback so it stays visible
|
|
10
|
+
* even where `--editor-accent` is out of scope, e.g. a body-portaled modal). The
|
|
11
|
+
* clap is a SMIL `animateTransform` rotating the clapper about its hinge — no
|
|
12
|
+
* CSS transform-origin ambiguity, no keyframe injection.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface LoaderProps {
|
|
16
|
+
/** Rendered pixel size. sm 20 (inline/thumbnail), md 34, lg 56 (hero). */
|
|
17
|
+
size?: 'sm' | 'md' | 'lg'
|
|
18
|
+
/** Optional caption under the mark (e.g. "Loading cover"). */
|
|
19
|
+
label?: string
|
|
20
|
+
/** Extra classes on the wrapper (layout/positioning). */
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PX: Record<NonNullable<LoaderProps['size']>, number> = { sm: 20, md: 34, lg: 56 }
|
|
25
|
+
|
|
26
|
+
export function Loader({ size = 'md', label, className = '' }: LoaderProps) {
|
|
27
|
+
const px = PX[size]
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
role="status"
|
|
31
|
+
aria-label={label ?? 'Loading'}
|
|
32
|
+
className={`flex flex-col items-center justify-center gap-2 ${className}`}
|
|
33
|
+
>
|
|
34
|
+
<svg width={px} height={px} viewBox="0 0 64 56" fill="none" aria-hidden="true">
|
|
35
|
+
{/* Slate (the board) + the Montaj play triangle. */}
|
|
36
|
+
<rect x="8" y="24" width="48" height="24" rx="5" fill="var(--editor-accent, #38bdf8)" />
|
|
37
|
+
<path d="M28 31 L41 36 L28 41 Z" fill="#ffffff" fillOpacity="0.92" />
|
|
38
|
+
|
|
39
|
+
{/* Clapper stick — hinged at the slate's top-left (11,24). Rests open,
|
|
40
|
+
snaps shut (the clap), reopens; repeat. */}
|
|
41
|
+
<g>
|
|
42
|
+
<animateTransform
|
|
43
|
+
attributeName="transform"
|
|
44
|
+
attributeType="XML"
|
|
45
|
+
type="rotate"
|
|
46
|
+
values="-32 11 24; -32 11 24; 0 11 24; -32 11 24"
|
|
47
|
+
keyTimes="0; 0.32; 0.46; 1"
|
|
48
|
+
dur="1.1s"
|
|
49
|
+
repeatCount="indefinite"
|
|
50
|
+
calcMode="spline"
|
|
51
|
+
keySplines="0 0 1 1; 0.45 0 0.3 1; 0.4 0 0.2 1"
|
|
52
|
+
/>
|
|
53
|
+
<rect x="8" y="14" width="48" height="10" rx="3" fill="#0b1220" />
|
|
54
|
+
<path d="M15 14 l7 10 h7 l-7 -10 z" fill="#ec4899" />
|
|
55
|
+
<path d="M29 14 l7 10 h7 l-7 -10 z" fill="#f97316" />
|
|
56
|
+
<path d="M43 14 l7 10 h7 l-7 -10 z" fill="#facc15" />
|
|
57
|
+
</g>
|
|
58
|
+
</svg>
|
|
59
|
+
{label && <span className="text-[11px] text-[var(--editor-text)]/50">{label}</span>}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default Loader
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import { ChevronDown, ChevronUp } from 'lucide-react'
|
|
3
|
+
import { cn } from './utils'
|
|
4
|
+
import { inspectorInputClass } from './input'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `stepValue` — the arithmetic behind ONE stepper click: nudge `value` by one
|
|
8
|
+
* `step` in `direction`, clamp to `[min, max]`, then round away the float noise
|
|
9
|
+
* the nudge itself introduces (`1.2 + 0.01` is `1.2100000000000002`, and a
|
|
10
|
+
* stepper is supposed to be exact).
|
|
11
|
+
*
|
|
12
|
+
* Exported and separate from the component because a stepper's arithmetic is
|
|
13
|
+
* NOT always the input's own `step`/`min`/`max`: the Transform panel's Scale
|
|
14
|
+
* box displays a PERCENTAGE (bounded 5–400) while its stepper nudges the
|
|
15
|
+
* stored multiplier (`0.01`, floored at `0.01`). The component therefore
|
|
16
|
+
* delegates stepping to its caller (see {@link NumberFieldProps.onStep}) and
|
|
17
|
+
* callers share this helper, rather than the component guessing which unit the
|
|
18
|
+
* nudge is in and every call site re-deriving the same three lines.
|
|
19
|
+
*
|
|
20
|
+
* Rounds to 2 decimal places, matching what the panel DISPLAYS — a stepper that
|
|
21
|
+
* lands on a value the box then rounds off would drift a little further from
|
|
22
|
+
* the shown number on every click.
|
|
23
|
+
*/
|
|
24
|
+
export function stepValue(
|
|
25
|
+
value: number,
|
|
26
|
+
direction: 1 | -1,
|
|
27
|
+
{ step, min, max }: { step: number; min?: number; max?: number },
|
|
28
|
+
): number {
|
|
29
|
+
let next = value + direction * step
|
|
30
|
+
if (min !== undefined) next = Math.max(min, next)
|
|
31
|
+
if (max !== undefined) next = Math.min(max, next)
|
|
32
|
+
return Math.round(next * 100) / 100
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NumberFieldProps {
|
|
36
|
+
/** The control's ACCESSIBLE name — the box's `aria-label`, and the stem of
|
|
37
|
+
* both stepper labels (`Increase ${name}` / `Decrease ${name}`). Kept
|
|
38
|
+
* deliberately separate from any visible label the caller renders beside
|
|
39
|
+
* the field: accessible names are a behavioural contract that tests and
|
|
40
|
+
* assistive tech both depend on, so visible chrome can be restyled without
|
|
41
|
+
* touching them. */
|
|
42
|
+
name: string
|
|
43
|
+
/** The live value. Owned by the caller — this component is controlled and
|
|
44
|
+
* holds no value state of its own beyond the mid-typing `draft` below.
|
|
45
|
+
*
|
|
46
|
+
* A STRING is allowed, and `''` specifically means "no explicit value": the
|
|
47
|
+
* box reads blank and its {@link NumberFieldProps.placeholder} reports
|
|
48
|
+
* whatever default is actually in force. That is not the same as zero, and
|
|
49
|
+
* a field that showed the default as a real value would invite the operator
|
|
50
|
+
* to save a value they never chose. */
|
|
51
|
+
value: number | string
|
|
52
|
+
/** Live-preview a typed edit: fired on EVERY keystroke that parses to a
|
|
53
|
+
* finite number. Empty, a lone `-`, and anything else unparseable are
|
|
54
|
+
* swallowed here rather than by each caller — mid-typing states are not
|
|
55
|
+
* edits and must not reach an undo stack or a save. */
|
|
56
|
+
onPreview: (value: number) => void
|
|
57
|
+
/** Close the typing gesture — ONE undo entry for the whole run of keystrokes,
|
|
58
|
+
* carrying the last value {@link NumberFieldProps.onPreview} emitted.
|
|
59
|
+
*
|
|
60
|
+
* Fired on blur, and on Enter via that same blur, but ONLY when the gesture
|
|
61
|
+
* actually previewed something: focusing a field and leaving it untouched
|
|
62
|
+
* writes nothing, because that would be a pointless save and an undo entry
|
|
63
|
+
* for a no-op. That guard is also what stops Enter-then-blur from
|
|
64
|
+
* committing twice.
|
|
65
|
+
*
|
|
66
|
+
* Takes the value even though some callers ignore it: a zero-argument
|
|
67
|
+
* handler is assignable to this signature, so the wider shape serves both
|
|
68
|
+
* the callers that close over their own last-previewed value and the ones
|
|
69
|
+
* that want the number handed to them. */
|
|
70
|
+
onCommit: (value: number) => void
|
|
71
|
+
/** One DISCRETE nudge — the caller is expected to emit one final edit (one
|
|
72
|
+
* undo entry), NOT a preview/commit pair. Omit to render no steppers.
|
|
73
|
+
* Takes a direction rather than a value because the nudge's unit is the
|
|
74
|
+
* caller's business; see {@link stepValue}. */
|
|
75
|
+
onStep?: (direction: 1 | -1) => void
|
|
76
|
+
/** Native `step`/`min`/`max` for the box.
|
|
77
|
+
*
|
|
78
|
+
* These drive the browser's own validation and native spinner ONLY. A TYPED
|
|
79
|
+
* value is deliberately NOT clamped against them, for two reasons: clamping
|
|
80
|
+
* between keystrokes fights the operator (typing `0.5` into a `min={1}` box
|
|
81
|
+
* passes through `0`, and `5` into a `max={400}` one passes through `5` on
|
|
82
|
+
* the way to `500`), and the two call sites disagree about whether a typed
|
|
83
|
+
* value should be bounded at all — the Transform panel's opacity row lets a
|
|
84
|
+
* typed `5` through where the caption panel's line height clamps `9` to
|
|
85
|
+
* `2.5`. So the component stays dumb and emits the raw parsed number; a
|
|
86
|
+
* caller that wants a bounded write clamps inside its own `onPreview`.
|
|
87
|
+
* The STEPPERS always clamp — that arithmetic is {@link stepValue}'s. */
|
|
88
|
+
step?: number
|
|
89
|
+
min?: number
|
|
90
|
+
max?: number
|
|
91
|
+
/** Shown when the box is blank. An empty string is meaningful and is
|
|
92
|
+
* rendered as a real empty `placeholder` attribute — a caller whose default
|
|
93
|
+
* has no value to report needs the attribute present and empty, not absent. */
|
|
94
|
+
placeholder?: string
|
|
95
|
+
/** Visible leading axis marker (`X`, `Y`). Purely decorative and hidden from
|
|
96
|
+
* assistive tech — the accessible name already carries the axis. */
|
|
97
|
+
prefix?: string
|
|
98
|
+
/** Visible trailing unit (`%`, `px`, `em`). Decorative, same reasoning. */
|
|
99
|
+
unit?: string
|
|
100
|
+
/** Passed through so a caller can wire a `<label htmlFor>` to the box. */
|
|
101
|
+
id?: string
|
|
102
|
+
/** Sizing/alignment override for the `<input>` itself (the wrapper's layout
|
|
103
|
+
* is fixed). Merged over {@link inspectorInputClass} via `cn`. */
|
|
104
|
+
className?: string
|
|
105
|
+
/** Disables the box AND its steppers together — a caller never wants one
|
|
106
|
+
* live while the other is dead (e.g. ClipPropertiesPanel's ducking
|
|
107
|
+
* depth/attack/release, inert until the Ducking switch is on). Styled the
|
|
108
|
+
* same `opacity-50 cursor-not-allowed` treatment ClipPropertiesPanel's
|
|
109
|
+
* `DraftField` already uses for its own disabled fields, so a disabled
|
|
110
|
+
* number box reads identically wherever it appears. */
|
|
111
|
+
disabled?: boolean
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `<NumberField>` — the editor's ONE compact number control: a typeable box
|
|
116
|
+
* with up/down steppers, an optional axis prefix and unit, and the two-phase
|
|
117
|
+
* preview/commit typing gesture.
|
|
118
|
+
*
|
|
119
|
+
* `draft` is the fix for a real bug, and the main reason this is a component
|
|
120
|
+
* rather than a bare `<input>`. Panels that host these fields re-render on
|
|
121
|
+
* every playback tick (~60Hz) whether or not the thing being edited is even
|
|
122
|
+
* involved, and a CONTROLLED input whose `value` comes straight from the
|
|
123
|
+
* tick-recomputed prop has no memory of what is mid-typed — typing into a
|
|
124
|
+
* field while the timeline played back got silently overwritten between
|
|
125
|
+
* keystrokes. `draft` breaks that link: it goes non-null the moment the
|
|
126
|
+
* operator types (first `onChange`) and stays the single source of truth for
|
|
127
|
+
* what is ON SCREEN until blur/Enter commits it, so ticks arriving mid-edit
|
|
128
|
+
* change `value` underneath without ever touching what is displayed. While
|
|
129
|
+
* `draft` is null (the common case — not currently being typed into) the field
|
|
130
|
+
* tracks `value` live, exactly as an uncontrolled-feeling box should,
|
|
131
|
+
* including the desired case of watching it animate during scrub/playback.
|
|
132
|
+
*
|
|
133
|
+
* Purely presentational and controlled: it never parses meaning out of the
|
|
134
|
+
* number, never decides what a step is worth, never bounds what is typed, and
|
|
135
|
+
* never reaches into a store. Keyframe diamonds, align buttons and row labels
|
|
136
|
+
* belong to the caller.
|
|
137
|
+
*/
|
|
138
|
+
export function NumberField({
|
|
139
|
+
name,
|
|
140
|
+
value,
|
|
141
|
+
onPreview,
|
|
142
|
+
onCommit,
|
|
143
|
+
onStep,
|
|
144
|
+
step,
|
|
145
|
+
min,
|
|
146
|
+
max,
|
|
147
|
+
placeholder,
|
|
148
|
+
prefix,
|
|
149
|
+
unit,
|
|
150
|
+
id,
|
|
151
|
+
className,
|
|
152
|
+
disabled,
|
|
153
|
+
}: NumberFieldProps) {
|
|
154
|
+
const [draft, setDraft] = useState<string | null>(null)
|
|
155
|
+
// Whether this typing gesture previewed anything worth committing, and what
|
|
156
|
+
// the last such preview was. A ref, not state: neither changes what is
|
|
157
|
+
// rendered, and a re-render between the keystroke and the blur would be the
|
|
158
|
+
// very tick `draft` exists to survive.
|
|
159
|
+
//
|
|
160
|
+
// Dirty-based rather than comparing the incoming `value` prop against
|
|
161
|
+
// `last.current`: live preview has ALREADY written the pending number into
|
|
162
|
+
// the project by the time blur fires (that is the whole point of
|
|
163
|
+
// `onPreview`), so by blur `value` typically equals the pending value
|
|
164
|
+
// anyway. A value-comparison guard ("only commit if it changed") would read
|
|
165
|
+
// that as nothing to do and skip the commit entirely — silently losing an
|
|
166
|
+
// edit that every caller believes it already made. `dirty` asks the
|
|
167
|
+
// question this component can actually answer honestly: did a keystroke
|
|
168
|
+
// happen, not does the prop look different yet.
|
|
169
|
+
const dirty = useRef(false)
|
|
170
|
+
const last = useRef(0)
|
|
171
|
+
const shown = draft ?? String(value)
|
|
172
|
+
|
|
173
|
+
function handleChange(raw: string) {
|
|
174
|
+
setDraft(raw)
|
|
175
|
+
// Mid-typing states (empty field, a lone minus, an unparseable fragment)
|
|
176
|
+
// are not edits — hold the draft on screen but emit nothing, and leave the
|
|
177
|
+
// gesture un-armed so blurring out of one commits nothing either.
|
|
178
|
+
if (raw.trim() === '') return
|
|
179
|
+
const parsed = Number(raw)
|
|
180
|
+
if (!Number.isFinite(parsed)) return
|
|
181
|
+
dirty.current = true
|
|
182
|
+
last.current = parsed
|
|
183
|
+
onPreview(parsed)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function commit() {
|
|
187
|
+
// Releasing the draft is unconditional — a box left blank or unparseable
|
|
188
|
+
// has to snap back to the value the project actually holds rather than sit
|
|
189
|
+
// there misreporting it. Committing is not: see `onCommit`'s contract.
|
|
190
|
+
setDraft(null)
|
|
191
|
+
if (!dirty.current) return
|
|
192
|
+
dirty.current = false
|
|
193
|
+
onCommit(last.current)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
198
|
+
{prefix && (
|
|
199
|
+
<span aria-hidden="true" className="w-2 shrink-0 text-[10px] text-[var(--editor-text)]/40">
|
|
200
|
+
{prefix}
|
|
201
|
+
</span>
|
|
202
|
+
)}
|
|
203
|
+
<input
|
|
204
|
+
id={id}
|
|
205
|
+
type="number"
|
|
206
|
+
aria-label={name}
|
|
207
|
+
step={step}
|
|
208
|
+
min={min}
|
|
209
|
+
max={max}
|
|
210
|
+
placeholder={placeholder}
|
|
211
|
+
value={shown}
|
|
212
|
+
disabled={disabled}
|
|
213
|
+
onChange={e => handleChange(e.target.value)}
|
|
214
|
+
onBlur={commit}
|
|
215
|
+
// Enter closes the typing gesture the same way blur does — it does not
|
|
216
|
+
// duplicate the commit logic, just triggers the same onBlur path.
|
|
217
|
+
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
|
218
|
+
className={cn(inspectorInputClass, 'text-right disabled:opacity-50 disabled:cursor-not-allowed', className)}
|
|
219
|
+
/>
|
|
220
|
+
{unit && (
|
|
221
|
+
<span aria-hidden="true" className="text-[10px] text-[var(--editor-text)]/40">
|
|
222
|
+
{unit}
|
|
223
|
+
</span>
|
|
224
|
+
)}
|
|
225
|
+
{onStep && <Stepper name={name} onStep={onStep} disabled={disabled} />}
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Up/down nudge buttons for one number box. Each click is a DISCRETE edit —
|
|
231
|
+
* one final change, one undo entry — not a preview/commit pair.
|
|
232
|
+
*
|
|
233
|
+
* `disabled` rides along from the box: a disabled input with live arrows
|
|
234
|
+
* next to it would let the operator nudge a value the box itself refuses to
|
|
235
|
+
* show as editable, so the buttons carry the SAME native `disabled`
|
|
236
|
+
* attribute (which already blocks both the click and any keyboard
|
|
237
|
+
* activation — no separate pointer-events/aria bookkeeping needed) rather
|
|
238
|
+
* than only being dimmed for show. */
|
|
239
|
+
function Stepper({ name, onStep, disabled }: { name: string; onStep: (direction: 1 | -1) => void; disabled?: boolean }) {
|
|
240
|
+
const btn = cn(
|
|
241
|
+
'flex h-3 w-4 items-center justify-center rounded-sm text-[var(--editor-text)]/45 transition-colors',
|
|
242
|
+
disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]',
|
|
243
|
+
)
|
|
244
|
+
return (
|
|
245
|
+
<div className="flex shrink-0 flex-col gap-px">
|
|
246
|
+
<button type="button" aria-label={`Increase ${name}`} onClick={() => onStep(1)} disabled={disabled} className={btn}>
|
|
247
|
+
<ChevronUp size={9} />
|
|
248
|
+
</button>
|
|
249
|
+
<button type="button" aria-label={`Decrease ${name}`} onClick={() => onStep(-1)} disabled={disabled} className={btn}>
|
|
250
|
+
<ChevronDown size={9} />
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
import { cn } from './utils'
|
|
3
|
+
|
|
4
|
+
export interface SliderProps {
|
|
5
|
+
/** The live value. Owned by the caller — this control is fully controlled. */
|
|
6
|
+
value: number
|
|
7
|
+
min: number
|
|
8
|
+
max: number
|
|
9
|
+
step?: number
|
|
10
|
+
/** Live-preview a drag/keyboard adjustment: fired on EVERY change, no undo
|
|
11
|
+
* entry, no save. Named `onChange` rather than `onPreview` because a caller
|
|
12
|
+
* that wants no two-phase gesture at all (a local scrub, say) simply omits
|
|
13
|
+
* {@link SliderProps.onCommit} and this is its only callback. */
|
|
14
|
+
onChange: (value: number) => void
|
|
15
|
+
/** Close the gesture — one undo entry for the whole drag. Fired at most ONCE
|
|
16
|
+
* per gesture, and only when the gesture actually moved something; see the
|
|
17
|
+
* `dirty` ref below. Omit for a slider whose changes are already final (or
|
|
18
|
+
* are purely local view state).
|
|
19
|
+
*
|
|
20
|
+
* Takes the value even though some callers ignore it: a zero-argument
|
|
21
|
+
* handler is assignable to this signature, so the wider shape serves both
|
|
22
|
+
* the callers that close over their own last-previewed value and the ones
|
|
23
|
+
* that want the number handed to them. */
|
|
24
|
+
onCommit?: (value: number) => void
|
|
25
|
+
/** Accessible name. Required — the control has no visible text of its own,
|
|
26
|
+
* and every call site sits beside a separate label element rather than
|
|
27
|
+
* inside one. */
|
|
28
|
+
'aria-label': string
|
|
29
|
+
/** Passed through so a caller can wire a `<label htmlFor>` to the input. */
|
|
30
|
+
id?: string
|
|
31
|
+
/** Sizing override. Defaults to full width; a flex-row caller typically
|
|
32
|
+
* passes `min-w-0 flex-1`. */
|
|
33
|
+
className?: string
|
|
34
|
+
disabled?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Track + thumb chrome. Split out as a constant so the comment explaining WHY
|
|
39
|
+
* it looks like this isn't wedged into the middle of the JSX.
|
|
40
|
+
*
|
|
41
|
+
* `appearance-none` strips the platform slider, after which Chrome/Safari take
|
|
42
|
+
* the track styling off the INPUT itself (hence `h-1` + `bg-` + `rounded-full`
|
|
43
|
+
* on the element) while Firefox needs `::-moz-range-track` said separately.
|
|
44
|
+
* The thumb has to be restated per engine for the same reason — there is no
|
|
45
|
+
* cross-engine selector for it, and a bare `accent-color` cannot produce the
|
|
46
|
+
* thin-track/round-thumb pairing at all: it colors the platform widget, whose
|
|
47
|
+
* track thickness and thumb shape are not ours to set.
|
|
48
|
+
*
|
|
49
|
+
* Every color is a `--editor-*` var, never a literal, so the control follows
|
|
50
|
+
* the host's light/dark theme like the rest of the editor chrome. The accent
|
|
51
|
+
* var is Montaj's indigo in both themes (see theme.ts), which is where the
|
|
52
|
+
* indigo thumb comes from.
|
|
53
|
+
*/
|
|
54
|
+
const SLIDER_CLASS = cn(
|
|
55
|
+
'h-1 w-full cursor-pointer appearance-none rounded-full bg-[var(--editor-border)] outline-none',
|
|
56
|
+
'focus-visible:ring-1 focus-visible:ring-[var(--editor-accent)]',
|
|
57
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
58
|
+
// WebKit/Blink: track is the element, thumb is the pseudo-element.
|
|
59
|
+
'[&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none',
|
|
60
|
+
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-0',
|
|
61
|
+
'[&::-webkit-slider-thumb]:bg-[var(--editor-accent)] [&::-webkit-slider-thumb]:cursor-grab',
|
|
62
|
+
// Gecko: both track and thumb are pseudo-elements.
|
|
63
|
+
'[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[var(--editor-border)]',
|
|
64
|
+
'[&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:rounded-full',
|
|
65
|
+
'[&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-[var(--editor-accent)] [&::-moz-range-thumb]:cursor-grab',
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `<Slider>` — the editor's ONE horizontal range control.
|
|
70
|
+
*
|
|
71
|
+
* Previews per change and commits ONCE per gesture: `dirty` arms on the first
|
|
72
|
+
* change and is disarmed by whichever gesture-end fires first, so the pointerup
|
|
73
|
+
* that is immediately followed by a blur cannot stack two undo entries onto the
|
|
74
|
+
* same drag. `onKeyUp` is in that set so a keyboard adjustment commits too, and
|
|
75
|
+
* a gesture that never changed anything commits nothing at all — a stray click
|
|
76
|
+
* on the track's current position must not spend an undo entry on a no-op.
|
|
77
|
+
*
|
|
78
|
+
* Still a native `<input type="range">` underneath, deliberately: it is
|
|
79
|
+
* keyboard-operable, exposes `role="slider"` with correct value semantics, and
|
|
80
|
+
* handles pointer capture for free. Only its LOOK is replaced.
|
|
81
|
+
*/
|
|
82
|
+
export function Slider({
|
|
83
|
+
value,
|
|
84
|
+
min,
|
|
85
|
+
max,
|
|
86
|
+
step,
|
|
87
|
+
onChange,
|
|
88
|
+
onCommit,
|
|
89
|
+
'aria-label': ariaLabel,
|
|
90
|
+
id,
|
|
91
|
+
className,
|
|
92
|
+
disabled,
|
|
93
|
+
}: SliderProps) {
|
|
94
|
+
const dirty = useRef(false)
|
|
95
|
+
// The last value actually previewed, so `end` reports what the caller saw
|
|
96
|
+
// rather than re-reading the DOM (blur's target is the input, but so is a
|
|
97
|
+
// programmatic one — the ref is simply the value we know we emitted).
|
|
98
|
+
const last = useRef(value)
|
|
99
|
+
|
|
100
|
+
function end() {
|
|
101
|
+
if (!dirty.current) return
|
|
102
|
+
dirty.current = false
|
|
103
|
+
onCommit?.(last.current)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<input
|
|
108
|
+
id={id}
|
|
109
|
+
type="range"
|
|
110
|
+
aria-label={ariaLabel}
|
|
111
|
+
min={min}
|
|
112
|
+
max={max}
|
|
113
|
+
step={step}
|
|
114
|
+
value={value}
|
|
115
|
+
disabled={disabled}
|
|
116
|
+
onChange={e => {
|
|
117
|
+
const next = Number(e.target.value)
|
|
118
|
+
dirty.current = true
|
|
119
|
+
last.current = next
|
|
120
|
+
onChange(next)
|
|
121
|
+
}}
|
|
122
|
+
onPointerUp={end}
|
|
123
|
+
onKeyUp={end}
|
|
124
|
+
onBlur={end}
|
|
125
|
+
className={cn(SLIDER_CLASS, className)}
|
|
126
|
+
/>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A small dark pill that appears instantly on hover — the editor's replacement
|
|
6
|
+
* for native `title=` on toolbar buttons, which browsers delay ~1s and render
|
|
7
|
+
* in OS chrome nobody notices.
|
|
8
|
+
*
|
|
9
|
+
* ── Why a portal ─────────────────────────────────────────────────────────
|
|
10
|
+
* The tool buttons live inside containers that clip: the timeline's zoom row
|
|
11
|
+
* sits above an `overflow-x-auto` scroller (which computes `overflow-y` to
|
|
12
|
+
* `auto`, so it clips vertically too), and the editor body is `overflow-hidden`.
|
|
13
|
+
* An absolutely-positioned pill inside the trigger would be cut off by both.
|
|
14
|
+
* Rendering into `document.body` at fixed coordinates measured from the
|
|
15
|
+
* trigger sidesteps every ancestor's overflow.
|
|
16
|
+
*
|
|
17
|
+
* The trigger is wrapped in an `inline-flex` span rather than cloned, so the
|
|
18
|
+
* wrapper becomes the flex item in the toolbar row and the button inside keeps
|
|
19
|
+
* its own box untouched. Pass `className` to move layout utilities that used
|
|
20
|
+
* to sit on the button (e.g. `mr-auto`) onto the wrapper.
|
|
21
|
+
*/
|
|
22
|
+
export interface TooltipProps {
|
|
23
|
+
/** Terse label — "Undo", "Split at playhead". Not a sentence. */
|
|
24
|
+
label: string
|
|
25
|
+
/** Optional shortcut chips rendered after the label, e.g. `['⌘', 'Z']`. */
|
|
26
|
+
keys?: string[]
|
|
27
|
+
/** Which side of the trigger the pill sits on. */
|
|
28
|
+
side?: 'top' | 'bottom'
|
|
29
|
+
/** Layout classes for the wrapper span (it is the flex item, not the child). */
|
|
30
|
+
className?: string
|
|
31
|
+
children: ReactNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Gap in px between the trigger's edge and the pill. */
|
|
35
|
+
const OFFSET_PX = 6
|
|
36
|
+
/** Keeps the pill from hanging off either edge of the window. */
|
|
37
|
+
const VIEWPORT_MARGIN_PX = 4
|
|
38
|
+
|
|
39
|
+
interface Position {
|
|
40
|
+
x: number
|
|
41
|
+
y: number
|
|
42
|
+
side: 'top' | 'bottom'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function Tooltip({ label, keys, side = 'top', className, children }: TooltipProps) {
|
|
46
|
+
const wrapperRef = useRef<HTMLSpanElement>(null)
|
|
47
|
+
const pillRef = useRef<HTMLDivElement>(null)
|
|
48
|
+
const [position, setPosition] = useState<Position | null>(null)
|
|
49
|
+
|
|
50
|
+
const show = useCallback(() => {
|
|
51
|
+
const rect = wrapperRef.current?.getBoundingClientRect()
|
|
52
|
+
if (!rect) return
|
|
53
|
+
setPosition({
|
|
54
|
+
x: rect.left + rect.width / 2,
|
|
55
|
+
y: side === 'top' ? rect.top - OFFSET_PX : rect.bottom + OFFSET_PX,
|
|
56
|
+
side,
|
|
57
|
+
})
|
|
58
|
+
}, [side])
|
|
59
|
+
|
|
60
|
+
const hide = useCallback(() => setPosition(null), [])
|
|
61
|
+
|
|
62
|
+
// Measured after paint, once the pill has a width: a button near the right
|
|
63
|
+
// edge of the window would otherwise render a centred pill that overflows.
|
|
64
|
+
// Clamping here (rather than at `show`) is what lets it use the real width.
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
if (!position) return
|
|
67
|
+
const pill = pillRef.current
|
|
68
|
+
if (!pill) return
|
|
69
|
+
const half = pill.offsetWidth / 2
|
|
70
|
+
const min = VIEWPORT_MARGIN_PX + half
|
|
71
|
+
const max = window.innerWidth - VIEWPORT_MARGIN_PX - half
|
|
72
|
+
if (max < min) return
|
|
73
|
+
const clamped = Math.max(min, Math.min(max, position.x))
|
|
74
|
+
if (clamped !== position.x) setPosition({ ...position, x: clamped })
|
|
75
|
+
}, [position])
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<span
|
|
79
|
+
ref={wrapperRef}
|
|
80
|
+
className={className ? `inline-flex ${className}` : 'inline-flex'}
|
|
81
|
+
onMouseEnter={show}
|
|
82
|
+
onMouseLeave={hide}
|
|
83
|
+
// A click that opens a modal (or disables the button) leaves the pill
|
|
84
|
+
// orphaned with no `mouseleave` to follow, so dismiss on press too.
|
|
85
|
+
onMouseDown={hide}
|
|
86
|
+
// Keyboard users get the same affordance; `focusout` covers tabbing away.
|
|
87
|
+
onFocus={show}
|
|
88
|
+
onBlur={hide}
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
{position && typeof document !== 'undefined' && createPortal(
|
|
92
|
+
<div
|
|
93
|
+
ref={pillRef}
|
|
94
|
+
role="tooltip"
|
|
95
|
+
className="pointer-events-none fixed z-[100] flex items-center gap-1 whitespace-nowrap rounded border border-[var(--editor-border)] bg-[var(--editor-surface)] px-1.5 py-1 text-[10px] leading-none text-[var(--editor-text)] shadow-lg"
|
|
96
|
+
style={{
|
|
97
|
+
left: position.x,
|
|
98
|
+
top: position.y,
|
|
99
|
+
transform: `translate(-50%, ${position.side === 'top' ? '-100%' : '0'})`,
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<span>{label}</span>
|
|
103
|
+
{keys?.map((k, i) => (
|
|
104
|
+
<kbd
|
|
105
|
+
key={i}
|
|
106
|
+
className="rounded border border-[var(--editor-border)] bg-[var(--editor-text)]/10 px-1 py-0.5 font-mono text-[9px] leading-none text-[var(--editor-text)]/70"
|
|
107
|
+
>
|
|
108
|
+
{k}
|
|
109
|
+
</kbd>
|
|
110
|
+
))}
|
|
111
|
+
</div>,
|
|
112
|
+
document.body,
|
|
113
|
+
)}
|
|
114
|
+
</span>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default Tooltip
|