@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,187 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../../ui'
|
|
3
|
+
import { usePersistentState } from '../../ui/usePersistentState'
|
|
4
|
+
|
|
5
|
+
export interface LeftPanelTab {
|
|
6
|
+
/** Stable id, also the persisted value. */
|
|
7
|
+
id: string
|
|
8
|
+
/** Rendered above the label in the rail. Callers pass a lucide icon element. */
|
|
9
|
+
icon: ReactNode
|
|
10
|
+
/** Short rail label, also the tab's accessible name. */
|
|
11
|
+
label: string
|
|
12
|
+
/** Panel body. Lazily mounted (see below). */
|
|
13
|
+
content: ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LeftPanelTabsProps {
|
|
17
|
+
tabs: LeftPanelTab[]
|
|
18
|
+
/** Tab shown when nothing is persisted (or the persisted id is stale).
|
|
19
|
+
* Defaults to `tabs[0].id` when omitted or not found in `tabs`. */
|
|
20
|
+
defaultTabId?: string
|
|
21
|
+
/** localStorage key for the active-tab preference.
|
|
22
|
+
* Default: 'montaj.editor.leftPanelTab'. */
|
|
23
|
+
storageKey?: string
|
|
24
|
+
/** Host-driven tab activation. When `nonce` changes to a positive value, the
|
|
25
|
+
* panel switches to `id` (if it names a real tab). Each activation is a
|
|
26
|
+
* fresh `nonce` so re-selecting the same target still switches — this stays
|
|
27
|
+
* generic (it never learns what a "caption" is): the editor bumps the nonce
|
|
28
|
+
* when a caption is selected so the panel jumps to Captions. `nonce` 0 (the
|
|
29
|
+
* initial value) never forces a tab, so a persisted preference wins on mount. */
|
|
30
|
+
activationRequest?: { id: string; nonce: number }
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_STORAGE_KEY = 'montaj.editor.leftPanelTab'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generic, presentational tabbed shell for the editor's left browser panel.
|
|
38
|
+
* Knows nothing about media/captions/versions — the host supplies tabs and
|
|
39
|
+
* this component only handles the rail, persistence, and lazy mounting.
|
|
40
|
+
*/
|
|
41
|
+
export default function LeftPanelTabs({ tabs, defaultTabId, storageKey = DEFAULT_STORAGE_KEY, activationRequest, className }: LeftPanelTabsProps) {
|
|
42
|
+
const baseId = useId()
|
|
43
|
+
const buttonRefs = useRef(new Map<string, HTMLButtonElement | null>())
|
|
44
|
+
|
|
45
|
+
const resolvedDefaultId =
|
|
46
|
+
defaultTabId !== undefined && tabs.some(t => t.id === defaultTabId) ? defaultTabId : tabs[0]?.id
|
|
47
|
+
|
|
48
|
+
const [activeId, setActiveId] = usePersistentState<string>(
|
|
49
|
+
storageKey,
|
|
50
|
+
resolvedDefaultId ?? '',
|
|
51
|
+
// Reject anything that isn't a string, or that isn't the id of a tab
|
|
52
|
+
// that exists right now — a stale id from an older build (a tab that's
|
|
53
|
+
// since been renamed or removed) falls back to the default rather than
|
|
54
|
+
// rendering a blank pane.
|
|
55
|
+
raw => (typeof raw === 'string' && tabs.some(t => t.id === raw) ? raw : null),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Host-driven activation (see `activationRequest`). Keyed ONLY on the nonce:
|
|
59
|
+
// `tabs` is a fresh array every render, so depending on it would re-fire the
|
|
60
|
+
// effect (and stomp the user's manual tab choice) on every render. When the
|
|
61
|
+
// nonce ticks we read the current `id`/`tabs` from this render's closure,
|
|
62
|
+
// which are up to date, so there is no stale-value risk.
|
|
63
|
+
const activationNonce = activationRequest?.nonce ?? 0
|
|
64
|
+
const activationId = activationRequest?.id
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (activationNonce <= 0 || !activationId) return
|
|
67
|
+
if (tabs.some(t => t.id === activationId)) setActiveId(activationId)
|
|
68
|
+
// Intentionally keyed on the nonce alone (see the note above); `tabs`/
|
|
69
|
+
// `setActiveId` are deliberately not deps. `react-hooks/exhaustive-deps`
|
|
70
|
+
// is not enabled in this package's eslint config, so no disable is used.
|
|
71
|
+
}, [activationNonce, activationId])
|
|
72
|
+
|
|
73
|
+
// Stale-tab safety at render time too: `tabs` can change after mount (a
|
|
74
|
+
// host swapping which tabs it offers). If the persisted/active id no
|
|
75
|
+
// longer names a real tab, fall back to the resolved default instead of
|
|
76
|
+
// rendering nothing.
|
|
77
|
+
const currentId = tabs.some(t => t.id === activeId) ? activeId : resolvedDefaultId
|
|
78
|
+
|
|
79
|
+
// Lazy mount, then keep mounted: a tab's `content` isn't rendered into the
|
|
80
|
+
// tree until that tab is first activated (the Media tab can be heavy and
|
|
81
|
+
// shouldn't mount until asked for), but once mounted it stays mounted and
|
|
82
|
+
// is only hidden on switch-away (the Captions tab owns sub-control state —
|
|
83
|
+
// sub-tabs, pickers, scroll position — that must survive a tab switch).
|
|
84
|
+
const [mountedIds, setMountedIds] = useState<Set<string>>(() => new Set(currentId ? [currentId] : []))
|
|
85
|
+
if (currentId && !mountedIds.has(currentId)) {
|
|
86
|
+
setMountedIds(prev => {
|
|
87
|
+
const next = new Set(prev)
|
|
88
|
+
next.add(currentId)
|
|
89
|
+
return next
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function moveFocus(nextId: string) {
|
|
94
|
+
setActiveId(nextId)
|
|
95
|
+
buttonRefs.current.get(nextId)?.focus()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, tabId: string) {
|
|
99
|
+
const ids = tabs.map(t => t.id)
|
|
100
|
+
const idx = ids.indexOf(tabId)
|
|
101
|
+
if (idx === -1) return
|
|
102
|
+
let nextIdx: number
|
|
103
|
+
switch (e.key) {
|
|
104
|
+
case 'ArrowDown':
|
|
105
|
+
case 'ArrowRight':
|
|
106
|
+
nextIdx = (idx + 1) % ids.length
|
|
107
|
+
break
|
|
108
|
+
case 'ArrowUp':
|
|
109
|
+
case 'ArrowLeft':
|
|
110
|
+
nextIdx = (idx - 1 + ids.length) % ids.length
|
|
111
|
+
break
|
|
112
|
+
case 'Home':
|
|
113
|
+
nextIdx = 0
|
|
114
|
+
break
|
|
115
|
+
case 'End':
|
|
116
|
+
nextIdx = ids.length - 1
|
|
117
|
+
break
|
|
118
|
+
default:
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
e.preventDefault()
|
|
122
|
+
moveFocus(ids[nextIdx])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className={cn('flex h-full min-h-0', className)}>
|
|
127
|
+
<div
|
|
128
|
+
role="tablist"
|
|
129
|
+
aria-orientation="vertical"
|
|
130
|
+
className="w-16 shrink-0 flex flex-col overflow-y-auto border-r border-[var(--editor-border)] bg-[var(--editor-bg)]"
|
|
131
|
+
>
|
|
132
|
+
{tabs.map(tab => {
|
|
133
|
+
const selected = tab.id === currentId
|
|
134
|
+
return (
|
|
135
|
+
<button
|
|
136
|
+
key={tab.id}
|
|
137
|
+
ref={el => { buttonRefs.current.set(tab.id, el) }}
|
|
138
|
+
type="button"
|
|
139
|
+
role="tab"
|
|
140
|
+
id={`${baseId}-tab-${tab.id}`}
|
|
141
|
+
aria-selected={selected}
|
|
142
|
+
aria-controls={`${baseId}-panel-${tab.id}`}
|
|
143
|
+
tabIndex={selected ? 0 : -1}
|
|
144
|
+
onClick={() => setActiveId(tab.id)}
|
|
145
|
+
onKeyDown={e => handleKeyDown(e, tab.id)}
|
|
146
|
+
className={cn(
|
|
147
|
+
'relative flex flex-col items-center gap-1 px-1 py-2.5 text-[10px] transition-colors',
|
|
148
|
+
selected
|
|
149
|
+
// Label is ~10px accent TEXT → the AA-safe accent-text token
|
|
150
|
+
// (indigo-600 in light); the tint fill keeps the plain accent.
|
|
151
|
+
? 'text-[var(--editor-accent-text)] bg-[var(--editor-accent)]/10'
|
|
152
|
+
: 'text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] hover:bg-[var(--editor-text)]/5',
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
{selected && (
|
|
156
|
+
<span className="absolute left-0 top-0 bottom-0 w-0.5 bg-[var(--editor-accent)]" aria-hidden="true" />
|
|
157
|
+
)}
|
|
158
|
+
{/* Decorative — the label carries the accessible name. */}
|
|
159
|
+
<span className="text-base leading-none" aria-hidden="true">{tab.icon}</span>
|
|
160
|
+
<span className="leading-tight text-center">{tab.label}</span>
|
|
161
|
+
</button>
|
|
162
|
+
)
|
|
163
|
+
})}
|
|
164
|
+
</div>
|
|
165
|
+
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
|
166
|
+
{tabs
|
|
167
|
+
.filter(tab => mountedIds.has(tab.id))
|
|
168
|
+
.map(tab => {
|
|
169
|
+
const active = tab.id === currentId
|
|
170
|
+
return (
|
|
171
|
+
<div
|
|
172
|
+
key={tab.id}
|
|
173
|
+
role="tabpanel"
|
|
174
|
+
id={`${baseId}-panel-${tab.id}`}
|
|
175
|
+
aria-labelledby={`${baseId}-tab-${tab.id}`}
|
|
176
|
+
hidden={!active}
|
|
177
|
+
style={active ? undefined : { display: 'none' }}
|
|
178
|
+
className="flex-1 min-h-0 flex flex-col overflow-hidden"
|
|
179
|
+
>
|
|
180
|
+
{tab.content}
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
})}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { useRef, useState, type ChangeEvent } from 'react'
|
|
2
|
+
import type { VisualItem } from '../../schema'
|
|
3
|
+
import { inferOverlayPropFields, type PropField } from '../preview/overlay-prop-fields'
|
|
4
|
+
import { cn, inspectorInputClass, NumberField, SwatchInput } from '../../ui'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `<OverlayContentPanel>` — the editor's contextual right-hand **Content**
|
|
8
|
+
* properties panel for the selected overlay: the overlay's own primitive
|
|
9
|
+
* props (its text, colors, numbers, toggles, image paths), as opposed to the
|
|
10
|
+
* transform geometry its sibling `OverlayInspector` edits.
|
|
11
|
+
*
|
|
12
|
+
* These fields used to live in the floating, draggable `OverlayPropsModal`,
|
|
13
|
+
* opened by a preview double-click / a Pencil button in the controls bar. That
|
|
14
|
+
* dialog is retired: this is the Content TAB of the same right column
|
|
15
|
+
* `OverlayInspector` (Transform) and `ClipPropertiesPanel` mount into, and it
|
|
16
|
+
* shares their visual language — one collapsible section, `var(--editor-*)`
|
|
17
|
+
* custom properties, `inspectorInputClass` inputs.
|
|
18
|
+
*
|
|
19
|
+
* ── No schema, by design ─────────────────────────────────────────────────
|
|
20
|
+
* The editable fields are INFERRED from whatever primitives the item's `props`
|
|
21
|
+
* happens to carry (see `inferOverlayPropFields`), which is why an AI-written
|
|
22
|
+
* one-off overlay is as editable as a shipped template. Non-primitive props
|
|
23
|
+
* (arrays, objects, null) are not shown and are never touched: every write
|
|
24
|
+
* spreads the item's whole `props` record, so they ride through untouched.
|
|
25
|
+
*
|
|
26
|
+
* ── Commit model ─────────────────────────────────────────────────────────
|
|
27
|
+
* Continuous edits (typing, dragging the OS color picker) PREVIEW per change
|
|
28
|
+
* and COMMIT ON BLUR — one undo step per typing gesture, matching
|
|
29
|
+
* `OverlayInspector`'s number boxes and `ClipPropertiesPanel`'s `DraftField`.
|
|
30
|
+
* Discrete edits (a checkbox, a finished image upload) have no blur to commit
|
|
31
|
+
* on, so they preview and commit back to back as one user-visible action —
|
|
32
|
+
* the same split those two panels make.
|
|
33
|
+
*
|
|
34
|
+
* The modal this replaces had a Save button and a Cancel that restored a
|
|
35
|
+
* pre-open snapshot. Neither survives: a panel is never "open", so there is no
|
|
36
|
+
* open-time snapshot to revert to and no moment at which a Save could be the
|
|
37
|
+
* thing that persists. Undo is the revert path now, and it works because each
|
|
38
|
+
* committed gesture is exactly one undo step.
|
|
39
|
+
*
|
|
40
|
+
* Props-driven and host-agnostic, like the rest of this package: it never
|
|
41
|
+
* reaches into a store. All persistence flows back through the two callbacks,
|
|
42
|
+
* which the host (VideoEditor) wires to its own sync core.
|
|
43
|
+
*/
|
|
44
|
+
export interface OverlayContentPanelProps {
|
|
45
|
+
/** The single selected item, or null. A null item and one with no editable
|
|
46
|
+
* props both render the empty state — the component enforces its own
|
|
47
|
+
* contract rather than trusting the caller to have already filtered. */
|
|
48
|
+
item: VisualItem | null
|
|
49
|
+
/** Live-preview an in-progress edit: no undo entry, no save yet. Carries the
|
|
50
|
+
* item's FULL next `props` record, not a patch — the caller replaces
|
|
51
|
+
* `item.props` wholesale. Mirrors `VideoEditor`'s `previewOverlayProps`. */
|
|
52
|
+
onPreview: (nextProps: Record<string, unknown>) => void
|
|
53
|
+
/** Commit the last previewed edit as one undo step + queued save. Fired on
|
|
54
|
+
* a field's blur, closing the typing gesture. */
|
|
55
|
+
onCommit: () => void
|
|
56
|
+
/** Resolve a workspace path to a servable URL (for image thumbnails). */
|
|
57
|
+
fileUrl?: (path: string) => string
|
|
58
|
+
/** Upload a picked file, returning its new workspace path (for image
|
|
59
|
+
* "change"). Absent on a non-Montaj host — see `ImageField`. */
|
|
60
|
+
uploadFile?: (file: File) => Promise<string>
|
|
61
|
+
/** Editor theme mode — light/dark. Only affects the image field's upload-
|
|
62
|
+
* error text (red-400 is sub-AA on a light `--editor-surface`). Absent ->
|
|
63
|
+
* dark, matching every existing caller. */
|
|
64
|
+
mode?: 'light' | 'dark'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SECTION_CLASS = 'shrink-0 border-b border-[var(--editor-border)] flex flex-col overflow-hidden'
|
|
68
|
+
const FIELD_LABEL_CLASS = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]/55'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Image prop control: a thumbnail preview plus a file picker that uploads the
|
|
72
|
+
* chosen file (via the host adapter) and swaps the prop to the returned path.
|
|
73
|
+
* When no `uploadFile` is available (non-Montaj host) it degrades to an
|
|
74
|
+
* editable path text field so the value is still reachable — carried over from
|
|
75
|
+
* `OverlayPropsModal` unchanged, because both halves handle real cases: a
|
|
76
|
+
* workspace path, an http URL, and a `data:image/…` URL all render here.
|
|
77
|
+
*
|
|
78
|
+
* A finished upload is a DISCRETE edit (`onChange`): there is no blur to
|
|
79
|
+
* commit on once the picker has closed.
|
|
80
|
+
*/
|
|
81
|
+
function ImageField({
|
|
82
|
+
name,
|
|
83
|
+
value,
|
|
84
|
+
fileUrl,
|
|
85
|
+
uploadFile,
|
|
86
|
+
onChange,
|
|
87
|
+
onInput,
|
|
88
|
+
onCommit,
|
|
89
|
+
mode = 'dark',
|
|
90
|
+
}: {
|
|
91
|
+
name: string
|
|
92
|
+
value: string
|
|
93
|
+
fileUrl?: (path: string) => string
|
|
94
|
+
uploadFile?: (file: File) => Promise<string>
|
|
95
|
+
/** Discrete — a completed upload. */
|
|
96
|
+
onChange: (v: string) => void
|
|
97
|
+
/** Continuous — a keystroke in the degraded path field. */
|
|
98
|
+
onInput: (v: string) => void
|
|
99
|
+
/** Closes the degraded path field's typing gesture. */
|
|
100
|
+
onCommit: () => void
|
|
101
|
+
mode?: 'light' | 'dark'
|
|
102
|
+
}) {
|
|
103
|
+
const [busy, setBusy] = useState(false)
|
|
104
|
+
const [err, setErr] = useState<string | null>(null)
|
|
105
|
+
const preview = value ? (fileUrl ? fileUrl(value) : value) : ''
|
|
106
|
+
|
|
107
|
+
async function onPick(e: ChangeEvent<HTMLInputElement>) {
|
|
108
|
+
const file = e.target.files?.[0]
|
|
109
|
+
e.target.value = '' // let the same file be re-picked later
|
|
110
|
+
if (!file || !uploadFile) return
|
|
111
|
+
setBusy(true)
|
|
112
|
+
setErr(null)
|
|
113
|
+
try {
|
|
114
|
+
onChange(await uploadFile(file))
|
|
115
|
+
} catch (x) {
|
|
116
|
+
setErr(String(x))
|
|
117
|
+
} finally {
|
|
118
|
+
setBusy(false)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="flex items-center gap-3">
|
|
124
|
+
{preview ? (
|
|
125
|
+
<img
|
|
126
|
+
src={preview}
|
|
127
|
+
alt={name}
|
|
128
|
+
className="h-14 w-14 shrink-0 rounded-md border border-[var(--editor-border)] object-cover bg-black/20"
|
|
129
|
+
/>
|
|
130
|
+
) : (
|
|
131
|
+
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--editor-border)] text-[10px] text-[var(--editor-text)]/40">
|
|
132
|
+
none
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
136
|
+
<span className="truncate font-mono text-xs text-[var(--editor-text)]/70" title={value}>
|
|
137
|
+
{value ? value.split('/').pop() : '—'}
|
|
138
|
+
</span>
|
|
139
|
+
{uploadFile ? (
|
|
140
|
+
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-[var(--editor-border)] px-2.5 py-1 text-xs text-[var(--editor-text)]/80 hover:bg-[var(--editor-text)]/5">
|
|
141
|
+
{busy ? 'Uploading…' : 'Change…'}
|
|
142
|
+
<input
|
|
143
|
+
type="file"
|
|
144
|
+
accept="image/*"
|
|
145
|
+
aria-label={name}
|
|
146
|
+
className="hidden"
|
|
147
|
+
onChange={onPick}
|
|
148
|
+
disabled={busy}
|
|
149
|
+
/>
|
|
150
|
+
</label>
|
|
151
|
+
) : (
|
|
152
|
+
<input
|
|
153
|
+
type="text"
|
|
154
|
+
aria-label={name}
|
|
155
|
+
value={value}
|
|
156
|
+
onChange={e => onInput(e.target.value)}
|
|
157
|
+
onBlur={onCommit}
|
|
158
|
+
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
|
159
|
+
className={cn(inspectorInputClass, 'font-mono')}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
162
|
+
{err && <span className={`text-[11px] ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>{err}</span>}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One inferred prop, rendered as the control its kind calls for, and the one
|
|
170
|
+
* place this panel's preview/commit split is decided.
|
|
171
|
+
*
|
|
172
|
+
* Split into its own component so it can hold its OWN state — hooks can't live
|
|
173
|
+
* inside a loop, and each field needs an independent "have I been edited"
|
|
174
|
+
* answer. `dirty` is that answer, and it is what stops a blur on an UNTOUCHED
|
|
175
|
+
* field from spending a save: `commit()` reaches `sync.commit()`, which
|
|
176
|
+
* enqueues a save whether or not a transient gesture actually happened. Same
|
|
177
|
+
* reasoning (and the same ref) as `OverlayInspector`'s `ScaleSlider`.
|
|
178
|
+
*
|
|
179
|
+
* `draft` is the fix for the same bug `ClipPropertiesPanel`'s `DraftField`
|
|
180
|
+
* documents: a controlled input bound straight to the incoming value gets its
|
|
181
|
+
* keystrokes clobbered by any re-render arriving mid-edit. It goes non-null
|
|
182
|
+
* the moment the operator types and stays the single source of truth for
|
|
183
|
+
* what's ON SCREEN until blur/Enter clears it; until then the field tracks the
|
|
184
|
+
* previewed value live.
|
|
185
|
+
*/
|
|
186
|
+
function PropFieldRow({
|
|
187
|
+
field,
|
|
188
|
+
onPreview,
|
|
189
|
+
onCommit,
|
|
190
|
+
fileUrl,
|
|
191
|
+
uploadFile,
|
|
192
|
+
mode = 'dark',
|
|
193
|
+
}: {
|
|
194
|
+
field: PropField
|
|
195
|
+
onPreview: (value: string | number | boolean) => void
|
|
196
|
+
onCommit: () => void
|
|
197
|
+
fileUrl?: (path: string) => string
|
|
198
|
+
uploadFile?: (file: File) => Promise<string>
|
|
199
|
+
mode?: 'light' | 'dark'
|
|
200
|
+
}) {
|
|
201
|
+
const [draft, setDraft] = useState<string | null>(null)
|
|
202
|
+
const dirty = useRef(false)
|
|
203
|
+
|
|
204
|
+
/** A continuous gesture step — no undo entry yet. */
|
|
205
|
+
function preview(value: string | number | boolean) {
|
|
206
|
+
dirty.current = true
|
|
207
|
+
onPreview(value)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Close a typing/picking gesture. A no-op on a field nobody touched. */
|
|
211
|
+
function commit() {
|
|
212
|
+
setDraft(null)
|
|
213
|
+
if (!dirty.current) return
|
|
214
|
+
dirty.current = false
|
|
215
|
+
onCommit()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A discrete, already-final edit — one undo step, no separate blur. */
|
|
219
|
+
function change(value: string | number | boolean) {
|
|
220
|
+
preview(value)
|
|
221
|
+
commit()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div className="flex flex-col gap-1">
|
|
226
|
+
<span className={FIELD_LABEL_CLASS}>{field.name}</span>
|
|
227
|
+
{field.kind === 'boolean' ? (
|
|
228
|
+
<input
|
|
229
|
+
type="checkbox"
|
|
230
|
+
aria-label={field.name}
|
|
231
|
+
checked={field.value as boolean}
|
|
232
|
+
onChange={e => change(e.target.checked)}
|
|
233
|
+
className="h-4 w-4 accent-[var(--editor-accent)]"
|
|
234
|
+
/>
|
|
235
|
+
) : field.kind === 'number' ? (
|
|
236
|
+
// The shared box (ui/NumberField.tsx) already owns the draft/
|
|
237
|
+
// preview/commit dance this branch used to hand-roll (including
|
|
238
|
+
// rejecting the mid-typing states `previewNumber` used to guard —
|
|
239
|
+
// an empty field, a lone minus). `step={1}` with no min/max: an
|
|
240
|
+
// inferred prop carries no schema (see this file's own doc comment),
|
|
241
|
+
// so there is no natural bound to hand the stepper either.
|
|
242
|
+
//
|
|
243
|
+
// The stepper deliberately does NOT go through `stepValue`, unlike
|
|
244
|
+
// every other adopter of this shared box. `stepValue` rounds to 2
|
|
245
|
+
// decimal places to match what the TRANSFORM panel displays — but
|
|
246
|
+
// this panel neither displays nor stores rounded values, and its
|
|
247
|
+
// props are SCHEMA-LESS floats (see this file's doc comment): an
|
|
248
|
+
// inferred prop sitting at `0.125` would become `1.13` on the very
|
|
249
|
+
// first arrow click and stay wrong forever. With `step: 1` there is
|
|
250
|
+
// no float noise to strip in the first place, so a plain integer
|
|
251
|
+
// nudge is already exact and `stepValue`'s rounding would only
|
|
252
|
+
// throw away precision this panel has no business touching.
|
|
253
|
+
<NumberField
|
|
254
|
+
name={field.name}
|
|
255
|
+
className="w-24"
|
|
256
|
+
step={1}
|
|
257
|
+
value={String(field.value)}
|
|
258
|
+
onPreview={v => preview(v)}
|
|
259
|
+
onCommit={() => commit()}
|
|
260
|
+
onStep={d => change(Number(field.value ?? 0) + d)}
|
|
261
|
+
/>
|
|
262
|
+
) : field.kind === 'color' ? (
|
|
263
|
+
// SwatchInput already splits live picking from the picker closing, so
|
|
264
|
+
// it maps onto preview/commit directly — no draft needed, a color
|
|
265
|
+
// input has no partial value to protect.
|
|
266
|
+
<SwatchInput
|
|
267
|
+
value={String(field.value)}
|
|
268
|
+
ariaLabel={field.name}
|
|
269
|
+
onChange={v => preview(v)}
|
|
270
|
+
onCommit={() => commit()}
|
|
271
|
+
/>
|
|
272
|
+
) : field.kind === 'image' ? (
|
|
273
|
+
<ImageField
|
|
274
|
+
name={field.name}
|
|
275
|
+
value={String(field.value)}
|
|
276
|
+
fileUrl={fileUrl}
|
|
277
|
+
uploadFile={uploadFile}
|
|
278
|
+
onChange={v => change(v)}
|
|
279
|
+
onInput={v => { setDraft(v); preview(v) }}
|
|
280
|
+
onCommit={commit}
|
|
281
|
+
mode={mode}
|
|
282
|
+
/>
|
|
283
|
+
) : (
|
|
284
|
+
<textarea
|
|
285
|
+
aria-label={field.name}
|
|
286
|
+
value={draft ?? String(field.value)}
|
|
287
|
+
rows={1}
|
|
288
|
+
onChange={e => { setDraft(e.target.value); preview(e.target.value) }}
|
|
289
|
+
onBlur={commit}
|
|
290
|
+
className={cn(inspectorInputClass, 'h-auto resize-y')}
|
|
291
|
+
/>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export default function OverlayContentPanel({
|
|
298
|
+
item,
|
|
299
|
+
onPreview,
|
|
300
|
+
onCommit,
|
|
301
|
+
fileUrl,
|
|
302
|
+
uploadFile,
|
|
303
|
+
mode = 'dark',
|
|
304
|
+
}: OverlayContentPanelProps) {
|
|
305
|
+
// Derived on EVERY render, never held in state. The modal seeded a `draft`
|
|
306
|
+
// array once in a `useState` initializer, which it could get away with
|
|
307
|
+
// because it remounted on every open; a panel that stays mounted while the
|
|
308
|
+
// operator clicks from overlay to overlay would keep showing the PREVIOUS
|
|
309
|
+
// overlay's fields. Same reasoning as OverlayInspector deriving `uniform`
|
|
310
|
+
// from the item rather than storing it.
|
|
311
|
+
const props = item?.props ?? {}
|
|
312
|
+
const fields = inferOverlayPropFields(props)
|
|
313
|
+
|
|
314
|
+
// Deliberately NO collapsible section header, unlike its siblings. The tab
|
|
315
|
+
// strip immediately above this already reads "Content", and a "CONTENT"
|
|
316
|
+
// section header under a "Content" tab is the same word twice in 40px of
|
|
317
|
+
// chrome — it also made `getByText('Content')` ambiguous at the VideoEditor
|
|
318
|
+
// seam. Collapsing is what the OTHER tab is for. The section FRAME stays, so
|
|
319
|
+
// the column's bottom border lines up whichever tab is showing.
|
|
320
|
+
//
|
|
321
|
+
// `OverlayInspector` still draws its own "TRANSFORM" header; folding that one
|
|
322
|
+
// away is a change to a file this task does not own.
|
|
323
|
+
return (
|
|
324
|
+
<div className={SECTION_CLASS}>
|
|
325
|
+
<div className="flex flex-col gap-3 p-2">
|
|
326
|
+
{fields.length === 0 ? (
|
|
327
|
+
<p className="px-1 py-4 text-center text-[11px] text-[var(--editor-text)]/45">
|
|
328
|
+
{item
|
|
329
|
+
? 'This overlay has no editable content.'
|
|
330
|
+
: 'Select an overlay to edit its content.'}
|
|
331
|
+
</p>
|
|
332
|
+
) : (
|
|
333
|
+
fields.map(field => (
|
|
334
|
+
<PropFieldRow
|
|
335
|
+
key={field.name}
|
|
336
|
+
field={field}
|
|
337
|
+
// Every write spreads the item's whole `props` record, so the
|
|
338
|
+
// non-primitive props `inferOverlayPropFields` skipped (arrays,
|
|
339
|
+
// objects, null) survive untouched rather than being dropped.
|
|
340
|
+
onPreview={value => onPreview({ ...props, [field.name]: value })}
|
|
341
|
+
onCommit={onCommit}
|
|
342
|
+
fileUrl={fileUrl}
|
|
343
|
+
uploadFile={uploadFile}
|
|
344
|
+
mode={mode}
|
|
345
|
+
/>
|
|
346
|
+
))
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
)
|
|
351
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { cn } from '../../ui'
|
|
2
|
+
|
|
3
|
+
export interface TabNavTab<T extends string = string> {
|
|
4
|
+
value: T
|
|
5
|
+
label: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TabNavProps<T extends string = string> {
|
|
9
|
+
tabs: readonly TabNavTab<T>[]
|
|
10
|
+
value: T
|
|
11
|
+
onChange: (next: T) => void
|
|
12
|
+
/** Accessible name for the group, e.g. "Overlay panel view". */
|
|
13
|
+
ariaLabel: string
|
|
14
|
+
/** Extra classes merged onto the wrapper, after the base `flex items-center`. */
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Shared underline tab strip used by the editor's small in-panel tab
|
|
20
|
+
* switches (CaptionListPanel's Styles/Format/Captions switch, VideoEditor's
|
|
21
|
+
* overlay Content/Transform pair, ClipPropertiesPanel's clip tabs):
|
|
22
|
+
* uppercase labels, an accent underline under the active tab, muted
|
|
23
|
+
* inactive — NOT a filled segmented control — so every panel that switches
|
|
24
|
+
* between two or three views speaks the same tab language. Purely
|
|
25
|
+
* presentational: the caller owns `value` and any persistence (e.g. via
|
|
26
|
+
* `usePersistentState`), this component only renders the strip and reports
|
|
27
|
+
* clicks.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately NOT ARIA `role="tab"` / `role="tablist"`: the editor's LEFT
|
|
30
|
+
* rail (`LeftPanelTabs`) already owns a real `role="tablist"`, and a second
|
|
31
|
+
* tablist anywhere else in the same tree would put ambiguous `role="tab"`
|
|
32
|
+
* nodes in the a11y tree and make `getByRole('tab', …)` ambiguous in the
|
|
33
|
+
* host's own tests. A plain `role="group"` of `aria-pressed` buttons carries
|
|
34
|
+
* the same "these are alternatives, one is active" meaning without the
|
|
35
|
+
* collision — do not "fix" this back to `role="tab"`.
|
|
36
|
+
*/
|
|
37
|
+
export default function TabNav<T extends string>({ tabs, value, onChange, ariaLabel, className }: TabNavProps<T>) {
|
|
38
|
+
return (
|
|
39
|
+
<div role="group" aria-label={ariaLabel} className={cn('flex items-center', className)}>
|
|
40
|
+
{tabs.map(tab => {
|
|
41
|
+
const active = tab.value === value
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
key={tab.value}
|
|
45
|
+
type="button"
|
|
46
|
+
aria-pressed={active}
|
|
47
|
+
onClick={() => onChange(tab.value)}
|
|
48
|
+
className={`px-3 py-2 text-[11px] font-medium uppercase tracking-wide border-b-2 -mb-px transition-colors ${
|
|
49
|
+
active
|
|
50
|
+
? 'border-[var(--editor-accent)] text-[var(--editor-text)]'
|
|
51
|
+
: 'border-transparent text-[var(--editor-text)] opacity-50 hover:opacity-80'
|
|
52
|
+
}`}
|
|
53
|
+
>
|
|
54
|
+
{tab.label}
|
|
55
|
+
</button>
|
|
56
|
+
)
|
|
57
|
+
})}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|