@hyperframes/studio 0.8.6 → 0.8.8
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/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
- package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
- package/dist/assets/index-ng4tq6YE.js +428 -0
- package/dist/index.d.ts +108 -18
- package/dist/index.html +2 -2
- package/dist/index.js +10335 -7201
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +3 -5
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +253 -1
- package/src/hooks/timelineTrackVisibility.ts +207 -8
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +8 -8
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +8 -1
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +40 -1
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +143 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -74,6 +74,8 @@ export function MetricField({
|
|
|
74
74
|
onPointerDown: handleScrubPointerDown,
|
|
75
75
|
onPointerMove: handleScrubPointerMove,
|
|
76
76
|
onPointerUp: handleScrubPointerUp,
|
|
77
|
+
onPointerCancel: handleScrubPointerUp,
|
|
78
|
+
onLostPointerCapture: handleScrubPointerUp,
|
|
77
79
|
} as const)
|
|
78
80
|
: ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
|
|
79
81
|
|
|
@@ -180,6 +182,7 @@ export function SliderControl({
|
|
|
180
182
|
step={step}
|
|
181
183
|
value={draft}
|
|
182
184
|
disabled={disabled}
|
|
185
|
+
aria-label={trackName}
|
|
183
186
|
onChange={(e) => {
|
|
184
187
|
const n = Number(e.target.value);
|
|
185
188
|
setDraft(n);
|
|
@@ -228,6 +231,7 @@ export function SegmentedControl({
|
|
|
228
231
|
if (option.value !== value) track("segmented", trackName);
|
|
229
232
|
onChange(option.value);
|
|
230
233
|
}}
|
|
234
|
+
aria-pressed={option.value === value}
|
|
231
235
|
className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
|
|
232
236
|
option.value === value
|
|
233
237
|
? "bg-panel-hover text-white"
|
|
@@ -292,23 +296,15 @@ export function Section({
|
|
|
292
296
|
defaultCollapsed?: boolean;
|
|
293
297
|
}) {
|
|
294
298
|
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
295
|
-
const collapseIcon =
|
|
296
|
-
<svg
|
|
297
|
-
width="12"
|
|
298
|
-
height="12"
|
|
299
|
-
viewBox="0 0 12 12"
|
|
300
|
-
fill="none"
|
|
301
|
-
className="flex-shrink-0 text-panel-text-5"
|
|
302
|
-
>
|
|
303
|
-
<path d="M6 2.5v7M2.5 6h7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
|
304
|
-
</svg>
|
|
305
|
-
) : (
|
|
299
|
+
const collapseIcon = (
|
|
306
300
|
<svg
|
|
307
301
|
width="10"
|
|
308
302
|
height="10"
|
|
309
303
|
viewBox="0 0 10 10"
|
|
310
304
|
fill="currentColor"
|
|
311
|
-
className=
|
|
305
|
+
className={`flex-shrink-0 text-panel-text-5 transition-transform duration-150 ${
|
|
306
|
+
collapsed ? "-rotate-90" : ""
|
|
307
|
+
}`}
|
|
312
308
|
>
|
|
313
309
|
<path d="M2 3l3 4 3-4z" />
|
|
314
310
|
</svg>
|
|
@@ -322,6 +318,7 @@ export function Section({
|
|
|
322
318
|
<button
|
|
323
319
|
type="button"
|
|
324
320
|
onClick={() => setCollapsed((v) => !v)}
|
|
321
|
+
aria-expanded={!collapsed}
|
|
325
322
|
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
|
|
326
323
|
>
|
|
327
324
|
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
|
|
3
3
|
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
|
4
4
|
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
|
@@ -116,21 +116,40 @@ export function StyleSections({
|
|
|
116
116
|
: "Solid";
|
|
117
117
|
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
|
|
118
118
|
const imageUrl = extractBackgroundImageUrl(backgroundImage);
|
|
119
|
+
// Remember the last authored gradient so an exploratory Solid↔Gradient
|
|
120
|
+
// toggle restores it instead of destroying it with the default gradient.
|
|
121
|
+
const lastGradientRef = useRef<string | null>(
|
|
122
|
+
backgroundImage.includes("gradient") ? backgroundImage : null,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// The remembered gradient is per-element: reset on selection change so
|
|
126
|
+
// element A's gradient can't leak onto element B via the fill-mode toggle.
|
|
127
|
+
// (The effect below re-stores it when the new element has its own gradient.)
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
lastGradientRef.current = null;
|
|
130
|
+
}, [element.id, element.selector]);
|
|
119
131
|
|
|
120
132
|
useEffect(() => {
|
|
121
133
|
setPreferredFillMode(fillMode);
|
|
134
|
+
if (backgroundImage.includes("gradient")) {
|
|
135
|
+
lastGradientRef.current = backgroundImage;
|
|
136
|
+
}
|
|
122
137
|
}, [fillMode, element.id, element.selector, backgroundImage]);
|
|
123
138
|
|
|
124
139
|
const handleFillModeChange = (nextMode: string) => {
|
|
125
140
|
setPreferredFillMode(nextMode);
|
|
126
141
|
if (nextMode === "Solid") {
|
|
142
|
+
if (backgroundImage.includes("gradient")) {
|
|
143
|
+
lastGradientRef.current = backgroundImage;
|
|
144
|
+
}
|
|
127
145
|
onSetStyle("background-image", "none");
|
|
128
146
|
return;
|
|
129
147
|
}
|
|
130
148
|
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
|
|
131
149
|
onSetStyle(
|
|
132
150
|
"background-image",
|
|
133
|
-
|
|
151
|
+
lastGradientRef.current ??
|
|
152
|
+
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
|
|
134
153
|
);
|
|
135
154
|
}
|
|
136
155
|
};
|
|
@@ -69,7 +69,7 @@ export function createTransformCommitHandlers({
|
|
|
69
69
|
await commitAnimatedTransformValue(
|
|
70
70
|
axis,
|
|
71
71
|
parsed,
|
|
72
|
-
"
|
|
72
|
+
"This element's position can't be edited here yet — it is driven by its animation",
|
|
73
73
|
)
|
|
74
74
|
)
|
|
75
75
|
return;
|
|
@@ -91,7 +91,7 @@ export function createTransformCommitHandlers({
|
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
93
|
if (hasGsapAnimation) {
|
|
94
|
-
showToast?.("
|
|
94
|
+
showToast?.("This element's size can't be edited here yet — it is driven by its animation");
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
97
|
const current = readStudioBoxSize(element.element);
|
|
@@ -118,7 +118,7 @@ export function createTransformCommitHandlers({
|
|
|
118
118
|
await commitAnimatedTransformValue(
|
|
119
119
|
"rotation",
|
|
120
120
|
parsed,
|
|
121
|
-
"
|
|
121
|
+
"This element's rotation can't be edited here yet — it is driven by its animation",
|
|
122
122
|
)
|
|
123
123
|
)
|
|
124
124
|
return;
|
|
@@ -92,6 +92,8 @@ export interface PropertyPanelProps {
|
|
|
92
92
|
onRemoveTextField: (fieldKey: string) => void;
|
|
93
93
|
onAskAgent: () => void;
|
|
94
94
|
onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
|
|
95
|
+
/** B6: group two or more picked voice clips, atomically, one undo entry. */
|
|
96
|
+
onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>;
|
|
95
97
|
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
|
|
96
98
|
onAddMediaOverlay?: AddMediaOverlayHandler;
|
|
97
99
|
fontAssets?: ImportedFontAsset[];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append (or, on reapply, replace in place) a preset's nodes onto a chain,
|
|
3
|
+
* with the telemetry that decision needs — the write path the property
|
|
4
|
+
* panel's FX section and the timeline FX popover (C1) both use, so a preset
|
|
5
|
+
* applied from either surface counts the same way.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
|
|
9
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
10
|
+
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
|
|
11
|
+
import { trackPresetApplied } from "./audioFxTelemetry.js";
|
|
12
|
+
|
|
13
|
+
export function applyPresetToChain(
|
|
14
|
+
chain: HfAudioFxChain,
|
|
15
|
+
presetId: string,
|
|
16
|
+
trackKind: HfAudioNameKind | undefined,
|
|
17
|
+
): HfAudioFxChain | null {
|
|
18
|
+
const preset = getAudioFxPreset(presetId);
|
|
19
|
+
if (!preset) return null;
|
|
20
|
+
// Appends. Stacking a character preset onto an already-cleaned voice is a
|
|
21
|
+
// real thing to want, and replacing silently would throw work away — so
|
|
22
|
+
// the destructive option is a separate gesture, not the default one.
|
|
23
|
+
const next = applyAudioFxPreset(chain, preset);
|
|
24
|
+
// Re-applying replaces this preset's own nodes in place rather than
|
|
25
|
+
// appending a second copy, and the two are different decisions — worth
|
|
26
|
+
// telling apart in the numbers.
|
|
27
|
+
const reapply = chain.nodes.some((n) => n.fromPreset === preset.id);
|
|
28
|
+
trackPresetApplied(
|
|
29
|
+
preset.id,
|
|
30
|
+
preset.family,
|
|
31
|
+
preset.nodes.length,
|
|
32
|
+
reapply ? "reapply" : "append",
|
|
33
|
+
{ trackKind },
|
|
34
|
+
);
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B6's normative rule: plural voiceover carve always targets a group.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `useFxCarve.ts`, which owned all of this before the file grew
|
|
5
|
+
* past a size where "carve targets a group" was still one thing to read —
|
|
6
|
+
* same reason `useFxCarve.ts` itself was split out of
|
|
7
|
+
* `propertyPanelAudioFxGroup.tsx`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
|
|
11
|
+
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An id for a new voiceover group, de-duped against every id already in the
|
|
15
|
+
* document — group ids and plain element ids share one namespace, so both
|
|
16
|
+
* have to be checked.
|
|
17
|
+
*/
|
|
18
|
+
export function mintGroupId(doc: Document): string {
|
|
19
|
+
const taken = new Set([
|
|
20
|
+
...resolveAudioGroups(doc).map((g) => g.id),
|
|
21
|
+
...Array.from(doc.querySelectorAll("[id]")).map((el) => el.id),
|
|
22
|
+
]);
|
|
23
|
+
if (!taken.has("voiceover")) return "voiceover";
|
|
24
|
+
let n = 2;
|
|
25
|
+
while (taken.has(`voiceover-${n}`)) n += 1;
|
|
26
|
+
return `voiceover-${n}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
|
|
30
|
+
return typeof (value as { then?: unknown })?.then === "function";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Plural voiceover carve, always against a group — normative, not a
|
|
35
|
+
* suggestion (groups doc §1.6). Picking a second ungrouped voice clip mints a
|
|
36
|
+
* group behind the two of them and points the carve at it instead, the same
|
|
37
|
+
* way a hand-authored composition is expected to work; a source list already
|
|
38
|
+
* naming a group is left alone; this only fires on a run of plain clip ids.
|
|
39
|
+
*
|
|
40
|
+
* Returns the settings unchanged, synchronously, when there is nothing to do —
|
|
41
|
+
* NOT wrapped in a promise even though the caller may `await` the result. An
|
|
42
|
+
* `async` function always yields a microtask, which would push every
|
|
43
|
+
* `setCarve` call (grouped or not) one tick later than before this existed;
|
|
44
|
+
* several tests assert on `onSetAttributeQuiet` synchronously after mount and
|
|
45
|
+
* would miss that write.
|
|
46
|
+
*/
|
|
47
|
+
function withAutoGroupedSources(
|
|
48
|
+
doc: Document,
|
|
49
|
+
next: HfCarveSettings,
|
|
50
|
+
assignGroup: ((clipIds: readonly string[], groupId: string) => Promise<void>) | undefined,
|
|
51
|
+
): HfCarveSettings | Promise<HfCarveSettings> {
|
|
52
|
+
if (!assignGroup || next.sources.length < 2) return next;
|
|
53
|
+
const groupIds = new Set(resolveAudioGroups(doc).map((g) => g.id));
|
|
54
|
+
if (next.sources.some((id) => groupIds.has(id))) return next;
|
|
55
|
+
const groupId = mintGroupId(doc);
|
|
56
|
+
return assignGroup(next.sources, groupId).then(() => ({ ...next, sources: [groupId] }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `setCarve`'s first step: apply the auto-group rule, if a document and a
|
|
61
|
+
* write-back are both available.
|
|
62
|
+
*
|
|
63
|
+
* Deliberately NOT an `async function`: wrapping this in one would make every
|
|
64
|
+
* call return a promise-wrapped value, forcing the caller's `await` to yield
|
|
65
|
+
* a microtask even on the synchronous branch — the exact bug
|
|
66
|
+
* `withAutoGroupedSources`'s own sync-when-possible contract exists to avoid.
|
|
67
|
+
* The caller does the `isPromiseLike` check (re-exported from here) and only
|
|
68
|
+
* awaits the branch that is genuinely async.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveNextCarveSettings(
|
|
71
|
+
nextRaw: HfCarveSettings | null,
|
|
72
|
+
doc: Document | undefined,
|
|
73
|
+
assignGroup: ((clipIds: readonly string[], groupId: string) => Promise<void>) | undefined,
|
|
74
|
+
): HfCarveSettings | Promise<HfCarveSettings> | null {
|
|
75
|
+
return nextRaw && doc ? withAutoGroupedSources(doc, nextRaw, assignGroup) : nextRaw;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CarveCandidate {
|
|
79
|
+
id: string;
|
|
80
|
+
label: string;
|
|
81
|
+
kind: ReturnType<typeof classifyAudioName>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One row per ungrouped clip that overlaps the bed, one row per group that has
|
|
86
|
+
* ANY overlapping member (union, not per-clip — a narration group spanning
|
|
87
|
+
* the whole timeline is relevant even if each of its segments only overlaps
|
|
88
|
+
* part of the bed). Grouped members never appear individually.
|
|
89
|
+
*/
|
|
90
|
+
export function collectCarveCandidates(
|
|
91
|
+
doc: Document,
|
|
92
|
+
others: readonly HTMLAudioElement[],
|
|
93
|
+
overlapsBed: (a: Element) => boolean,
|
|
94
|
+
): CarveCandidate[] {
|
|
95
|
+
const groupByMember = new Map(
|
|
96
|
+
resolveAudioGroups(doc).flatMap((group) => group.memberIds.map((id) => [id, group] as const)),
|
|
97
|
+
);
|
|
98
|
+
const offeredGroupIds = new Set<string>();
|
|
99
|
+
const described: CarveCandidate[] = [];
|
|
100
|
+
for (const a of others) {
|
|
101
|
+
const group = groupByMember.get(a.id);
|
|
102
|
+
if (!group) {
|
|
103
|
+
if (overlapsBed(a)) {
|
|
104
|
+
described.push({
|
|
105
|
+
id: a.id,
|
|
106
|
+
label: a.id,
|
|
107
|
+
kind: classifyAudioName(a.id, a.getAttribute("src")),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (offeredGroupIds.has(group.id)) continue;
|
|
113
|
+
const members = group.memberIds
|
|
114
|
+
.map((id) => doc.getElementById(id))
|
|
115
|
+
// Not `instanceof HTMLAudioElement`: these belong to the composition's
|
|
116
|
+
// iframe document, so the constructor is a different realm's and the
|
|
117
|
+
// instanceof is false for every one (mirrors resolveCarveVoices in
|
|
118
|
+
// useFxCarve.ts).
|
|
119
|
+
.filter((el): el is HTMLElement => el?.tagName === "AUDIO");
|
|
120
|
+
if (!members.some(overlapsBed)) continue;
|
|
121
|
+
offeredGroupIds.add(group.id);
|
|
122
|
+
described.push({
|
|
123
|
+
id: group.id,
|
|
124
|
+
label: `${group.label} (${members.length})`,
|
|
125
|
+
kind: classifyAudioName(
|
|
126
|
+
group.label,
|
|
127
|
+
...members.flatMap((m) => [m.id, m.getAttribute("src")]),
|
|
128
|
+
),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return described;
|
|
132
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
2
|
import { CaptionOverlay } from "../../captions/components/CaptionOverlay";
|
|
3
|
+
import { useCaptionStore } from "../../captions/store";
|
|
3
4
|
import { DomEditOverlay } from "../editor/DomEditOverlay";
|
|
4
5
|
import { MotionPathOverlay } from "../editor/MotionPathOverlay";
|
|
5
6
|
import { SnapToolbar } from "../editor/SnapToolbar";
|
|
@@ -139,6 +140,24 @@ export function PreviewOverlays({
|
|
|
139
140
|
const { captionEditMode, compositionLoading, isPlaying } = useStudioPlaybackContext();
|
|
140
141
|
const compositionDimensions = useCompositionDimensions();
|
|
141
142
|
|
|
143
|
+
// Caption edit mode is entered automatically when captions are detected;
|
|
144
|
+
// these give the author an explicit way OUT (and back in). Without them the
|
|
145
|
+
// caption overlay permanently replaces normal element editing.
|
|
146
|
+
const captionModelPresent = useCaptionStore((state) => state.model !== null);
|
|
147
|
+
const captionDismissed = useCaptionStore((state) => state.dismissed);
|
|
148
|
+
const captionSyncError = useCaptionStore((state) => state.syncError);
|
|
149
|
+
const exitCaptionMode = useCallback(() => {
|
|
150
|
+
const store = useCaptionStore.getState();
|
|
151
|
+
store.clearSelection();
|
|
152
|
+
store.setDismissed(true);
|
|
153
|
+
store.setEditMode(false);
|
|
154
|
+
}, []);
|
|
155
|
+
const enterCaptionMode = useCallback(() => {
|
|
156
|
+
const store = useCaptionStore.getState();
|
|
157
|
+
store.setDismissed(false);
|
|
158
|
+
store.setEditMode(true);
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
142
161
|
const { domEditHoverSelection, domEditSelection, domEditGroupSelections } =
|
|
143
162
|
useDomEditSelectionContext();
|
|
144
163
|
const {
|
|
@@ -194,7 +213,46 @@ export function PreviewOverlays({
|
|
|
194
213
|
}
|
|
195
214
|
|
|
196
215
|
if (captionEditMode) {
|
|
197
|
-
return
|
|
216
|
+
return (
|
|
217
|
+
<>
|
|
218
|
+
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
219
|
+
{/* Mode indicator + explicit exit */}
|
|
220
|
+
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 rounded-full border border-studio-accent/40 bg-black/70 px-2.5 py-1">
|
|
221
|
+
<span className="h-1.5 w-1.5 rounded-full bg-studio-accent" aria-hidden="true" />
|
|
222
|
+
<span className="text-2xs text-neutral-200">Editing captions</span>
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
onClick={exitCaptionMode}
|
|
226
|
+
className="rounded text-2xs text-neutral-400 underline underline-offset-2 hover:text-neutral-100 focus-visible:outline focus-visible:outline-1 focus-visible:outline-studio-accent"
|
|
227
|
+
>
|
|
228
|
+
Exit
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
{captionSyncError && (
|
|
232
|
+
<div
|
|
233
|
+
role="alert"
|
|
234
|
+
className="absolute top-10 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 rounded-full border border-red-500/50 bg-red-950/90 px-2.5 py-1"
|
|
235
|
+
>
|
|
236
|
+
<span className="text-2xs text-red-200">{captionSyncError}</span>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
onClick={() => useCaptionStore.getState().retrySave?.()}
|
|
240
|
+
className="rounded text-2xs text-red-100 underline underline-offset-2 hover:text-white"
|
|
241
|
+
>
|
|
242
|
+
Retry
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => useCaptionStore.getState().setSyncError(null)}
|
|
247
|
+
aria-label="Dismiss"
|
|
248
|
+
className="rounded px-0.5 text-2xs text-red-300/70 hover:text-red-100"
|
|
249
|
+
>
|
|
250
|
+
✕
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</>
|
|
255
|
+
);
|
|
198
256
|
}
|
|
199
257
|
|
|
200
258
|
return (
|
|
@@ -270,6 +328,15 @@ export function PreviewOverlays({
|
|
|
270
328
|
isPlaying={isPlaying}
|
|
271
329
|
/>
|
|
272
330
|
{gestureOverlay}
|
|
331
|
+
{captionModelPresent && captionDismissed && (
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
onClick={enterCaptionMode}
|
|
335
|
+
className="absolute top-2 left-1/2 -translate-x-1/2 z-[60] rounded-full border border-neutral-700 bg-black/60 px-2.5 py-1 text-2xs text-neutral-300 transition-colors hover:border-studio-accent/50 hover:text-studio-accent focus-visible:outline focus-visible:outline-1 focus-visible:outline-studio-accent"
|
|
336
|
+
>
|
|
337
|
+
Edit captions
|
|
338
|
+
</button>
|
|
339
|
+
)}
|
|
273
340
|
</>
|
|
274
341
|
);
|
|
275
342
|
}
|
|
@@ -104,6 +104,7 @@ function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => v
|
|
|
104
104
|
handleTimelineElementResize: vi.fn(),
|
|
105
105
|
handleTimelineGroupResize: vi.fn(),
|
|
106
106
|
handleToggleTrackHidden: vi.fn(),
|
|
107
|
+
setAudioGroupAttribute: { setLive: vi.fn(), setQuiet: vi.fn() },
|
|
107
108
|
handleBlockedTimelineEdit: vi.fn(),
|
|
108
109
|
handleTimelineElementSplit: vi.fn(),
|
|
109
110
|
handleRazorSplit: vi.fn(),
|
|
@@ -38,10 +38,26 @@ export interface TimelineEditCallbackDeps {
|
|
|
38
38
|
) => Promise<void> | void;
|
|
39
39
|
handleTimelineGroupResize: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
|
|
40
40
|
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
|
|
41
|
+
setAudioGroupAttribute: {
|
|
42
|
+
setLive: (groupId: string, attr: string, value: string | null) => void;
|
|
43
|
+
setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
|
|
44
|
+
};
|
|
41
45
|
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
42
46
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
43
47
|
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
44
48
|
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
|
49
|
+
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
|
|
50
|
+
handleGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
|
|
51
|
+
/** C1's single-clip FX write, addressed by the clip itself. */
|
|
52
|
+
setElementFxAttribute?: {
|
|
53
|
+
setLive: (element: TimelineElement, attr: string, value: string | null) => void;
|
|
54
|
+
setQuiet: (
|
|
55
|
+
element: TimelineElement,
|
|
56
|
+
attr: string,
|
|
57
|
+
value: string | null,
|
|
58
|
+
label: string,
|
|
59
|
+
) => Promise<void>;
|
|
60
|
+
};
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
interface TimelineKeyframeTargetAnimation {
|
|
@@ -99,10 +115,13 @@ export function useTimelineEditCallbacks({
|
|
|
99
115
|
handleTimelineElementResize,
|
|
100
116
|
handleTimelineGroupResize,
|
|
101
117
|
handleToggleTrackHidden,
|
|
118
|
+
setAudioGroupAttribute,
|
|
102
119
|
handleBlockedTimelineEdit,
|
|
103
120
|
handleTimelineElementSplit,
|
|
104
121
|
handleRazorSplit,
|
|
105
122
|
handleRazorSplitAll,
|
|
123
|
+
handleGroupClips,
|
|
124
|
+
setElementFxAttribute,
|
|
106
125
|
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
|
|
107
126
|
const { projectId, activeCompPath } = useStudioShellContext();
|
|
108
127
|
const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
|
|
@@ -185,6 +204,11 @@ export function useTimelineEditCallbacks({
|
|
|
185
204
|
onResizeElement: handleTimelineElementResize,
|
|
186
205
|
onResizeElements: handleTimelineGroupResize,
|
|
187
206
|
onToggleTrackHidden: handleToggleTrackHidden,
|
|
207
|
+
onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
|
|
208
|
+
onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
|
|
209
|
+
onGroupClips: handleGroupClips,
|
|
210
|
+
onSetElementAttributeLive: setElementFxAttribute?.setLive,
|
|
211
|
+
onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
|
|
188
212
|
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
|
189
213
|
onSplitElement: handleTimelineElementSplit,
|
|
190
214
|
onRazorSplit: handleRazorSplit,
|
|
@@ -378,6 +402,9 @@ export function useTimelineEditCallbacks({
|
|
|
378
402
|
handleTimelineElementResize,
|
|
379
403
|
handleTimelineGroupResize,
|
|
380
404
|
handleToggleTrackHidden,
|
|
405
|
+
setAudioGroupAttribute,
|
|
406
|
+
handleGroupClips,
|
|
407
|
+
setElementFxAttribute,
|
|
381
408
|
handleBlockedTimelineEdit,
|
|
382
409
|
handleTimelineElementSplit,
|
|
383
410
|
handleRazorSplit,
|
|
@@ -183,6 +183,13 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
|
|
|
183
183
|
const [expandedSections, setExpandedSections] = useState<Set<SectionKey>>(
|
|
184
184
|
() => new Set<SectionKey>(["slides", "inspector"]),
|
|
185
185
|
);
|
|
186
|
+
// Persist failure surfacing: edits keep working in memory, but the user must
|
|
187
|
+
// know the file write failed (and be able to retry) — never silent data loss.
|
|
188
|
+
const [persistError, setPersistError] = useState(false);
|
|
189
|
+
const [retrying, setRetrying] = useState(false);
|
|
190
|
+
// In-panel undo history of manifest snapshots (discrete edits only).
|
|
191
|
+
const undoStackRef = useRef<SlideshowManifest[]>([]);
|
|
192
|
+
const [undoDepth, setUndoDepth] = useState(0);
|
|
186
193
|
|
|
187
194
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
188
195
|
const { domEditSelection } = useDomEditSelectionContext();
|
|
@@ -209,26 +216,56 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
|
|
|
209
216
|
setManifest(parsed);
|
|
210
217
|
manifestRef.current = parsed;
|
|
211
218
|
setSelectedSequenceId(null);
|
|
219
|
+
// History belongs to one composition — a switch starts fresh.
|
|
220
|
+
undoStackRef.current = [];
|
|
221
|
+
setUndoDepth(0);
|
|
222
|
+
setPersistError(false);
|
|
212
223
|
}, [compHtml]);
|
|
213
224
|
|
|
214
225
|
/** Discrete actions (toggle, reorder, add/delete, hotspot): persist immediately. */
|
|
215
226
|
const applyManifest = useCallback(
|
|
216
|
-
async (next: SlideshowManifest) => {
|
|
227
|
+
async (next: SlideshowManifest, opts?: { skipUndo?: boolean }) => {
|
|
217
228
|
// Fold any in-flight typed notes into the discrete manifest so they are
|
|
218
229
|
// not silently dropped when the debounce timer would have fired later.
|
|
219
230
|
const merged = notesCtrlRef.current.mergeIntoDiscrete(next);
|
|
231
|
+
if (!opts?.skipUndo) {
|
|
232
|
+
undoStackRef.current.push(manifestRef.current);
|
|
233
|
+
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
|
234
|
+
setUndoDepth(undoStackRef.current.length);
|
|
235
|
+
}
|
|
220
236
|
setManifest(merged);
|
|
221
237
|
manifestRef.current = merged;
|
|
222
238
|
// Surface persist failures instead of swallowing them at each call site.
|
|
223
239
|
try {
|
|
224
240
|
await onPersist(merged);
|
|
241
|
+
setPersistError(false);
|
|
225
242
|
} catch (err) {
|
|
226
243
|
console.error("[slideshow] failed to persist manifest edit:", err);
|
|
244
|
+
setPersistError(true);
|
|
227
245
|
}
|
|
228
246
|
},
|
|
229
247
|
[onPersist],
|
|
230
248
|
);
|
|
231
249
|
|
|
250
|
+
const handleUndo = useCallback(() => {
|
|
251
|
+
const prev = undoStackRef.current.pop();
|
|
252
|
+
if (!prev) return;
|
|
253
|
+
setUndoDepth(undoStackRef.current.length);
|
|
254
|
+
applyManifest(prev, { skipUndo: true }).catch(() => {});
|
|
255
|
+
}, [applyManifest]);
|
|
256
|
+
|
|
257
|
+
const handleRetryPersist = useCallback(async () => {
|
|
258
|
+
setRetrying(true);
|
|
259
|
+
try {
|
|
260
|
+
await onPersist(manifestRef.current);
|
|
261
|
+
setPersistError(false);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.error("[slideshow] retry persist failed:", err);
|
|
264
|
+
} finally {
|
|
265
|
+
setRetrying(false);
|
|
266
|
+
}
|
|
267
|
+
}, [onPersist]);
|
|
268
|
+
|
|
232
269
|
/**
|
|
233
270
|
* Notes path: update in-memory state immediately for a responsive UI, but
|
|
234
271
|
* debounce the disk persist to ~450 ms after the last keystroke. The pending
|
|
@@ -338,18 +375,10 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
|
|
|
338
375
|
[applyManifest],
|
|
339
376
|
);
|
|
340
377
|
|
|
378
|
+
// Confirmation is inline in BranchItem (consistent with the FileTree/assets
|
|
379
|
+
// delete pattern); by the time this fires the user has already confirmed.
|
|
341
380
|
const handleDeleteSequence = useCallback(
|
|
342
381
|
(id: string) => {
|
|
343
|
-
// Deleting a branch removes its slides and orphans any hotspot targeting it —
|
|
344
|
-
// confirm first to prevent accidental data loss.
|
|
345
|
-
const seq = (manifestRef.current.slideSequences ?? []).find((s) => s.id === id);
|
|
346
|
-
const count = seq?.slides.length ?? 0;
|
|
347
|
-
const label = seq?.label ?? id;
|
|
348
|
-
const ok = window.confirm(
|
|
349
|
-
`Delete branch "${label}"${count ? ` and its ${count} slide${count === 1 ? "" : "s"}` : ""}? ` +
|
|
350
|
-
`Hotspots pointing to it will no longer resolve.`,
|
|
351
|
-
);
|
|
352
|
-
if (!ok) return;
|
|
353
382
|
applyManifest(deleteSequence(manifestRef.current, id)).catch(() => {});
|
|
354
383
|
},
|
|
355
384
|
[applyManifest],
|
|
@@ -383,7 +412,47 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
|
|
|
383
412
|
);
|
|
384
413
|
|
|
385
414
|
return (
|
|
386
|
-
<div
|
|
415
|
+
<div
|
|
416
|
+
className="flex flex-col h-full overflow-y-auto text-white"
|
|
417
|
+
onKeyDown={(e) => {
|
|
418
|
+
// In-panel undo — scoped so it never fights the app-level file undo.
|
|
419
|
+
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z") {
|
|
420
|
+
const target = e.target instanceof HTMLElement ? e.target.tagName : "";
|
|
421
|
+
if (target === "TEXTAREA" || target === "INPUT") return;
|
|
422
|
+
e.preventDefault();
|
|
423
|
+
e.stopPropagation();
|
|
424
|
+
handleUndo();
|
|
425
|
+
}
|
|
426
|
+
}}
|
|
427
|
+
>
|
|
428
|
+
{persistError && (
|
|
429
|
+
<div
|
|
430
|
+
role="alert"
|
|
431
|
+
className="flex items-center justify-between gap-2 px-3 py-2 bg-red-950/40 border-b border-red-500/40"
|
|
432
|
+
>
|
|
433
|
+
<span className="text-[11px] text-red-300">Changes not saved</span>
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
disabled={retrying}
|
|
437
|
+
onClick={handleRetryPersist}
|
|
438
|
+
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white enabled:hover:bg-red-500 enabled:active:scale-[0.97] disabled:opacity-50 transition-colors"
|
|
439
|
+
>
|
|
440
|
+
{retrying ? "Retrying…" : "Retry"}
|
|
441
|
+
</button>
|
|
442
|
+
</div>
|
|
443
|
+
)}
|
|
444
|
+
{undoDepth > 0 && (
|
|
445
|
+
<div className="flex items-center justify-end px-3 py-1 border-b border-neutral-800/60">
|
|
446
|
+
<button
|
|
447
|
+
type="button"
|
|
448
|
+
onClick={handleUndo}
|
|
449
|
+
title="Undo last slideshow edit (⌘Z)"
|
|
450
|
+
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 active:scale-[0.97] transition-colors"
|
|
451
|
+
>
|
|
452
|
+
Undo ({undoDepth})
|
|
453
|
+
</button>
|
|
454
|
+
</div>
|
|
455
|
+
)}
|
|
387
456
|
<SectionHeader
|
|
388
457
|
expanded={expandedSections.has("slides")}
|
|
389
458
|
onToggle={() => toggleSection("slides")}
|