@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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The visibility control's accessible contract.
|
|
5
|
+
*
|
|
6
|
+
* The audio wording ran behind the `audio-track-mute` canary at 0%, so it had
|
|
7
|
+
* never rendered in any suite: it returned "Muted" / "Mute", which named the
|
|
8
|
+
* CURRENT state rather than the action, and dropped the track suffix so every
|
|
9
|
+
* audio row shared one accessible name. Music plus VO is the ordinary case, so
|
|
10
|
+
* that is two identical buttons. Pinned here because the label and the icon are
|
|
11
|
+
* the whole identity of this control.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { act } from "react";
|
|
15
|
+
import { createRoot } from "react-dom/client";
|
|
16
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
import { PlainTrackHeader, VisibilityButton } from "./TimelineTrackPlainHeader";
|
|
18
|
+
|
|
19
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
document.body.innerHTML = "";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function renderButton(props: {
|
|
26
|
+
hidden: boolean;
|
|
27
|
+
isAudioTrack?: boolean;
|
|
28
|
+
trackDisplayNumber: number | null;
|
|
29
|
+
}): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
|
|
30
|
+
const host = document.createElement("div");
|
|
31
|
+
document.body.append(host);
|
|
32
|
+
const root = createRoot(host);
|
|
33
|
+
const onToggle = vi.fn();
|
|
34
|
+
act(() =>
|
|
35
|
+
root.render(
|
|
36
|
+
React.createElement(VisibilityButton, {
|
|
37
|
+
hidden: props.hidden,
|
|
38
|
+
trackNumber: 7,
|
|
39
|
+
trackDisplayNumber: props.trackDisplayNumber,
|
|
40
|
+
visible: true,
|
|
41
|
+
isAudioTrack: props.isAudioTrack,
|
|
42
|
+
onToggle,
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
return { host, unmount: () => act(() => root.unmount()), onToggle };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const labelOf = (host: HTMLElement) => host.querySelector("button")?.getAttribute("aria-label");
|
|
50
|
+
|
|
51
|
+
describe("VisibilityButton", () => {
|
|
52
|
+
it("names the action, not the state, on an audible audio track", () => {
|
|
53
|
+
const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
|
|
54
|
+
expect(labelOf(view.host)).toBe("Mute track 2");
|
|
55
|
+
view.unmount();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The half that was wrong: a muted row read "Muted", so nothing told a
|
|
59
|
+
// screen-reader user that activating it would unmute.
|
|
60
|
+
it("promises the un-mute when the audio track is already muted", () => {
|
|
61
|
+
const view = renderButton({ hidden: true, isAudioTrack: true, trackDisplayNumber: 2 });
|
|
62
|
+
expect(labelOf(view.host)).toBe("Unmute track 2");
|
|
63
|
+
view.unmount();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("keeps each audio row's name unique, so two tracks are distinguishable", () => {
|
|
67
|
+
const first = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 1 });
|
|
68
|
+
const second = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 3 });
|
|
69
|
+
expect(labelOf(first.host)).toBe("Mute track 1");
|
|
70
|
+
expect(labelOf(second.host)).toBe("Mute track 3");
|
|
71
|
+
expect(labelOf(first.host)).not.toBe(labelOf(second.host));
|
|
72
|
+
first.unmount();
|
|
73
|
+
second.unmount();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("still says Hide/Show on a visual track", () => {
|
|
77
|
+
const shown = renderButton({ hidden: false, trackDisplayNumber: 2 });
|
|
78
|
+
expect(labelOf(shown.host)).toBe("Hide track 2");
|
|
79
|
+
shown.unmount();
|
|
80
|
+
const hiddenRow = renderButton({ hidden: true, trackDisplayNumber: 2 });
|
|
81
|
+
expect(labelOf(hiddenRow.host)).toBe("Show track 2");
|
|
82
|
+
hiddenRow.unmount();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// The callback acts on the REAL track key; the display row rides along so the
|
|
86
|
+
// undo-history label announces the same row this button just did, instead of
|
|
87
|
+
// re-deriving it from an ordering that has no group anchors in it.
|
|
88
|
+
it("toggles the real track number and passes the row it announced", () => {
|
|
89
|
+
const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
|
|
90
|
+
view.host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
91
|
+
expect(view.onToggle).toHaveBeenCalledWith(7, true, 2);
|
|
92
|
+
view.unmount();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("PlainTrackHeader", () => {
|
|
97
|
+
function renderHeader(overrides: Partial<Parameters<typeof PlainTrackHeader>[0]> = {}) {
|
|
98
|
+
const host = document.createElement("div");
|
|
99
|
+
document.body.append(host);
|
|
100
|
+
const root = createRoot(host);
|
|
101
|
+
const onToggleSolo = vi.fn();
|
|
102
|
+
act(() =>
|
|
103
|
+
root.render(
|
|
104
|
+
React.createElement(PlainTrackHeader, {
|
|
105
|
+
trackNumber: 0,
|
|
106
|
+
trackDisplayNumber: 1,
|
|
107
|
+
trackLabel: "Voiceover",
|
|
108
|
+
clipCount: 1,
|
|
109
|
+
isTrackHidden: false,
|
|
110
|
+
isAudioTrack: true,
|
|
111
|
+
onToggleTrackHidden: vi.fn(),
|
|
112
|
+
showTrackLabel: true,
|
|
113
|
+
isGroupMuted: false,
|
|
114
|
+
isSoloed: false,
|
|
115
|
+
onToggleSolo,
|
|
116
|
+
...overrides,
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
return { host, unmount: () => act(() => root.unmount()), onToggleSolo };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const soloButton = (host: HTMLElement) =>
|
|
124
|
+
host.querySelector('button[aria-label="Hear only this"]');
|
|
125
|
+
const labelSpan = (host: HTMLElement) => host.querySelector("span.min-w-0");
|
|
126
|
+
|
|
127
|
+
it("offers solo on an audio track and reports its pressed state", () => {
|
|
128
|
+
const view = renderHeader({ isSoloed: true });
|
|
129
|
+
expect(soloButton(view.host)?.getAttribute("aria-pressed")).toBe("true");
|
|
130
|
+
view.unmount();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("withholds solo from a visual track", () => {
|
|
134
|
+
const view = renderHeader({ isAudioTrack: false });
|
|
135
|
+
expect(soloButton(view.host)).toBeNull();
|
|
136
|
+
view.unmount();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A group-muted member is silent without being hidden itself, so the strike
|
|
140
|
+
// is the only thing that says so — and the title has to explain why, since a
|
|
141
|
+
// user who never touched THIS row's mute is looking for the reason.
|
|
142
|
+
it("strikes the label through when the row's own mute is on", () => {
|
|
143
|
+
const view = renderHeader({ isTrackHidden: true });
|
|
144
|
+
expect(labelSpan(view.host)?.className).toContain("line-through");
|
|
145
|
+
view.unmount();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("strikes it through for a group mute too, and says which", () => {
|
|
149
|
+
const view = renderHeader({ isGroupMuted: true });
|
|
150
|
+
expect(labelSpan(view.host)?.className).toContain("line-through");
|
|
151
|
+
expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover (group muted)");
|
|
152
|
+
view.unmount();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("leaves an audible row unstruck", () => {
|
|
156
|
+
const view = renderHeader();
|
|
157
|
+
expect(labelSpan(view.host)?.className).not.toContain("line-through");
|
|
158
|
+
expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover");
|
|
159
|
+
view.unmount();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
|
2
|
+
import { Music } from "../../icons/SystemIcons";
|
|
3
|
+
import { TimelineSoloButton } from "./TimelineSoloButton";
|
|
4
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
5
|
+
import { TrackClipCount } from "./TrackClipCount";
|
|
6
|
+
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
|
|
10
|
+
*
|
|
11
|
+
* Action-phrased and per-track, like the visual branch: `hidden` here is the
|
|
12
|
+
* CURRENT state, so the name has to promise the opposite. It read "Muted" /
|
|
13
|
+
* "Mute" until the rollout, which named the state instead of the action (a
|
|
14
|
+
* screen-reader user could not tell that activating an already-muted row would
|
|
15
|
+
* unmute it) and dropped `suffix`, so every audio row shared one accessible
|
|
16
|
+
* name — music plus VO being the ordinary case. The wording matches the undo
|
|
17
|
+
* entry `timelineTrackVisibility` writes for the same click, where `hidden` is
|
|
18
|
+
* the INCOMING state and the two therefore read inverted.
|
|
19
|
+
*/
|
|
20
|
+
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
|
|
21
|
+
if (showAsMute) return hidden ? `Unmute track${suffix}` : `Mute track${suffix}`;
|
|
22
|
+
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) {
|
|
26
|
+
const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
|
|
27
|
+
return <Icon size={14} weight="bold" aria-hidden="true" />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function VisibilityButton({
|
|
31
|
+
hidden,
|
|
32
|
+
trackNumber,
|
|
33
|
+
trackDisplayNumber,
|
|
34
|
+
visible,
|
|
35
|
+
isAudioTrack,
|
|
36
|
+
onToggle,
|
|
37
|
+
}: {
|
|
38
|
+
hidden: boolean;
|
|
39
|
+
trackNumber: number;
|
|
40
|
+
trackDisplayNumber: number | null;
|
|
41
|
+
visible: boolean;
|
|
42
|
+
isAudioTrack?: boolean;
|
|
43
|
+
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
44
|
+
}) {
|
|
45
|
+
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
|
46
|
+
// Display number in the text, real key in the callback. The two must not be
|
|
47
|
+
// conflated in either direction.
|
|
48
|
+
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
|
49
|
+
const showAsMute = Boolean(isAudioTrack);
|
|
50
|
+
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
|
|
51
|
+
return (
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
aria-label={label}
|
|
55
|
+
title={label}
|
|
56
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
|
57
|
+
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
|
|
58
|
+
}`}
|
|
59
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
60
|
+
onClick={(event) => {
|
|
61
|
+
event.stopPropagation();
|
|
62
|
+
void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{visibilityButtonIcon(showAsMute, hidden)}
|
|
66
|
+
</button>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The header a track gets when it has no keyframe clip to disclose: label, clip
|
|
71
|
+
// count, eye. Not deprecated — it is the live path for every track without lanes.
|
|
72
|
+
export function PlainTrackHeader({
|
|
73
|
+
trackNumber,
|
|
74
|
+
trackDisplayNumber,
|
|
75
|
+
trackLabel,
|
|
76
|
+
clipCount,
|
|
77
|
+
showTrackLabel,
|
|
78
|
+
isTrackHidden,
|
|
79
|
+
isAudioTrack,
|
|
80
|
+
isGroupMuted,
|
|
81
|
+
isSoloed,
|
|
82
|
+
onToggleSolo,
|
|
83
|
+
onToggleTrackHidden,
|
|
84
|
+
}: {
|
|
85
|
+
trackNumber: number;
|
|
86
|
+
trackDisplayNumber: number | null;
|
|
87
|
+
trackLabel: string;
|
|
88
|
+
clipCount: number;
|
|
89
|
+
isTrackHidden: boolean;
|
|
90
|
+
isAudioTrack: boolean;
|
|
91
|
+
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
92
|
+
showTrackLabel: boolean;
|
|
93
|
+
isGroupMuted: boolean;
|
|
94
|
+
isSoloed: boolean;
|
|
95
|
+
onToggleSolo?: (options?: { add?: boolean }) => void;
|
|
96
|
+
}) {
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
{isAudioTrack && (
|
|
100
|
+
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
|
101
|
+
)}
|
|
102
|
+
{showTrackLabel && (
|
|
103
|
+
<span
|
|
104
|
+
className={`min-w-0 flex-1 truncate text-[11px] ${
|
|
105
|
+
isAudioTrack && (isTrackHidden || isGroupMuted) ? "line-through" : ""
|
|
106
|
+
}`}
|
|
107
|
+
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
|
|
108
|
+
>
|
|
109
|
+
{trackLabel}
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
|
113
|
+
<VisibilityButton
|
|
114
|
+
hidden={isTrackHidden}
|
|
115
|
+
trackNumber={trackNumber}
|
|
116
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
117
|
+
visible
|
|
118
|
+
isAudioTrack={isAudioTrack}
|
|
119
|
+
onToggle={onToggleTrackHidden}
|
|
120
|
+
/>
|
|
121
|
+
{isAudioTrack && onToggleSolo && (
|
|
122
|
+
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
|
|
123
|
+
)}
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -110,13 +110,18 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
110
110
|
)}
|
|
111
111
|
{snapshot.status === "loading" && urls.length === 0 && (
|
|
112
112
|
<div
|
|
113
|
-
className="absolute inset-0 animate-pulse"
|
|
113
|
+
className="absolute inset-0 animate-pulse motion-reduce:animate-none"
|
|
114
114
|
style={{
|
|
115
115
|
background:
|
|
116
116
|
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
117
117
|
}}
|
|
118
118
|
/>
|
|
119
119
|
)}
|
|
120
|
+
{snapshot.status === "error" && (
|
|
121
|
+
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900/60">
|
|
122
|
+
<span className="rounded bg-black/50 px-1 text-[8px] text-neutral-500">no preview</span>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
120
125
|
{label && (
|
|
121
126
|
<div
|
|
122
127
|
className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* APG menu keyboard basics for the timeline context menus: focuses the first
|
|
5
|
+
* menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and restores
|
|
6
|
+
* focus to the previously focused element when the menu unmounts. Pair with
|
|
7
|
+
* `role="menu"` on the container and `role="menuitem"` on the buttons
|
|
8
|
+
* (dismiss/Escape handling stays in useContextMenuDismiss).
|
|
9
|
+
*/
|
|
10
|
+
export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): void {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const menu = menuRef.current;
|
|
13
|
+
if (!menu) return;
|
|
14
|
+
const previouslyFocused = document.activeElement;
|
|
15
|
+
|
|
16
|
+
const items = () =>
|
|
17
|
+
Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')).filter(
|
|
18
|
+
(el) => !el.disabled,
|
|
19
|
+
);
|
|
20
|
+
items()[0]?.focus();
|
|
21
|
+
|
|
22
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
23
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const list = items();
|
|
27
|
+
if (list.length === 0) return;
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
const idx = list.findIndex((el) => el === document.activeElement);
|
|
30
|
+
let next: number;
|
|
31
|
+
if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % list.length;
|
|
32
|
+
else if (e.key === "ArrowUp")
|
|
33
|
+
next = idx < 0 ? list.length - 1 : (idx - 1 + list.length) % list.length;
|
|
34
|
+
else if (e.key === "Home") next = 0;
|
|
35
|
+
else next = list.length - 1;
|
|
36
|
+
list[next]?.focus();
|
|
37
|
+
};
|
|
38
|
+
menu.addEventListener("keydown", onKeyDown);
|
|
39
|
+
|
|
40
|
+
return () => {
|
|
41
|
+
menu.removeEventListener("keydown", onKeyDown);
|
|
42
|
+
if (previouslyFocused instanceof HTMLElement && document.contains(previouslyFocused)) {
|
|
43
|
+
previouslyFocused.focus();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}, [menuRef]);
|
|
47
|
+
}
|
|
@@ -67,7 +67,46 @@ export interface TimelineEditCallbacks {
|
|
|
67
67
|
}>,
|
|
68
68
|
options?: { coalesceKey?: string },
|
|
69
69
|
) => Promise<void> | void;
|
|
70
|
-
|
|
70
|
+
/**
|
|
71
|
+
* `displayNumber` is the row the CLICKED control announced. It travels with
|
|
72
|
+
* the click because the header and the undo-history label derive the row from
|
|
73
|
+
* two different orderings: the header's comes from the group-aware row list
|
|
74
|
+
* (synthetic anchor rows, members pulled contiguous), the history's from a
|
|
75
|
+
* plain ascending sort of element-bearing keys. Once a group exists those
|
|
76
|
+
* disagree, so the same click said "Mute track 2" and recorded "Mute track 1".
|
|
77
|
+
* Passing the rendered number keeps one answer instead of two derivations.
|
|
78
|
+
*/
|
|
79
|
+
onToggleTrackHidden?: (
|
|
80
|
+
track: number,
|
|
81
|
+
hidden: boolean,
|
|
82
|
+
displayNumber?: number | null,
|
|
83
|
+
) => Promise<void> | void;
|
|
84
|
+
/** B7's bus strip: live-write the group's own attribute while dragging. */
|
|
85
|
+
onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
|
|
86
|
+
/** ...and persist one undo entry on release. */
|
|
87
|
+
onSetAudioGroupAttributeQuiet?: (
|
|
88
|
+
groupId: string,
|
|
89
|
+
attr: string,
|
|
90
|
+
value: string | null,
|
|
91
|
+
label: string,
|
|
92
|
+
) => Promise<void>;
|
|
93
|
+
/** C1's ungrouped-track FX pointer: "Group these clips" — write
|
|
94
|
+
* `data-audio-group` on every one of them, atomically. Same shape B6's
|
|
95
|
+
* carve auto-grouping uses. */
|
|
96
|
+
onGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
|
|
97
|
+
/** C1's single-clip FX write: addressed by the clip itself rather than the
|
|
98
|
+
* current selection, mirroring `onSetAudioGroupAttributeLive/Quiet`. */
|
|
99
|
+
onSetElementAttributeLive?: (
|
|
100
|
+
element: TimelineElement,
|
|
101
|
+
attr: string,
|
|
102
|
+
value: string | null,
|
|
103
|
+
) => void;
|
|
104
|
+
onSetElementAttributeQuiet?: (
|
|
105
|
+
element: TimelineElement,
|
|
106
|
+
attr: string,
|
|
107
|
+
value: string | null,
|
|
108
|
+
label: string,
|
|
109
|
+
) => Promise<void>;
|
|
71
110
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
72
111
|
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
73
112
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
@@ -58,6 +58,10 @@ function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]
|
|
|
58
58
|
selectedElementId: "active",
|
|
59
59
|
selectedElementIds: new Set(),
|
|
60
60
|
expandedClipIds: new Set(["active"]),
|
|
61
|
+
expandedGroupIds: new Set(),
|
|
62
|
+
expandedLaneOwnerIds: new Set(),
|
|
63
|
+
groups: [],
|
|
64
|
+
trackGroupOf: new Map(),
|
|
61
65
|
gsapAnimations: new Map([
|
|
62
66
|
[
|
|
63
67
|
"active",
|
|
@@ -244,6 +248,10 @@ describe("resolveTimelineNavigationTarget", () => {
|
|
|
244
248
|
selectedElementId: null,
|
|
245
249
|
selectedElementIds: new Set(),
|
|
246
250
|
expandedClipIds: new Set(),
|
|
251
|
+
expandedGroupIds: new Set(),
|
|
252
|
+
expandedLaneOwnerIds: new Set(),
|
|
253
|
+
groups: [],
|
|
254
|
+
trackGroupOf: new Map(),
|
|
247
255
|
gsapAnimations: new Map(),
|
|
248
256
|
});
|
|
249
257
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
4
|
+
import { groupAutomationLanes } from "./automationLaneData";
|
|
4
5
|
import {
|
|
5
6
|
timelineKeyframeSelectionKey,
|
|
6
7
|
type TimelineKeyframeTarget,
|
|
@@ -8,11 +9,13 @@ import {
|
|
|
8
9
|
import {
|
|
9
10
|
timelineClipFocusId,
|
|
10
11
|
timelineEaseFocusId,
|
|
12
|
+
timelineGroupRowId,
|
|
11
13
|
timelineKeyframeFocusId,
|
|
12
14
|
timelinePropertyRowId,
|
|
13
15
|
timelineTrackRowId,
|
|
14
16
|
} from "./timelineNavigationIdentity";
|
|
15
17
|
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
|
18
|
+
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
16
19
|
|
|
17
20
|
export type TimelineNavigationKey =
|
|
18
21
|
| "ArrowLeft"
|
|
@@ -54,9 +57,11 @@ export interface TimelineLogicalRow {
|
|
|
54
57
|
kind: "row";
|
|
55
58
|
physicalTrackKey: number;
|
|
56
59
|
logicalIndex: number;
|
|
57
|
-
level: 1 | 2;
|
|
60
|
+
level: 1 | 2 | 3;
|
|
58
61
|
parentId: string | null;
|
|
59
62
|
elementId: string | null;
|
|
63
|
+
/** Set only on a group's own row (level 1, no clips of its own). */
|
|
64
|
+
groupId?: string;
|
|
60
65
|
expandable: boolean;
|
|
61
66
|
expanded: boolean;
|
|
62
67
|
propertyGroup?: PropertyGroupName;
|
|
@@ -65,13 +70,19 @@ export interface TimelineLogicalRow {
|
|
|
65
70
|
|
|
66
71
|
export type TimelineLogicalTarget = TimelineLogicalRow | TimelineLogicalItem;
|
|
67
72
|
|
|
68
|
-
interface BuildTimelineLogicalRowsInput {
|
|
73
|
+
export interface BuildTimelineLogicalRowsInput {
|
|
69
74
|
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
|
|
70
75
|
displayTrackOrder: readonly number[];
|
|
71
76
|
laneCounts: ReadonlyMap<string, number>;
|
|
72
77
|
selectedElementId: string | null;
|
|
73
78
|
selectedElementIds: ReadonlySet<string>;
|
|
74
79
|
expandedClipIds: ReadonlySet<string>;
|
|
80
|
+
/** Groups whose member rows the caret has shown (structural, not lanes). */
|
|
81
|
+
expandedGroupIds: ReadonlySet<string>;
|
|
82
|
+
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
|
|
83
|
+
expandedLaneOwnerIds: ReadonlySet<string>;
|
|
84
|
+
groups: readonly TimelineTrackGroupInfo[];
|
|
85
|
+
trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
|
|
75
86
|
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
|
|
76
87
|
}
|
|
77
88
|
|
|
@@ -106,6 +117,35 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin
|
|
|
106
117
|
});
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
/** A track's active clip (if any), its element id, and its automation lanes. */
|
|
121
|
+
function resolveActiveTrackClip(
|
|
122
|
+
elements: readonly TimelineElement[],
|
|
123
|
+
laneCounts: BuildTimelineLogicalRowsInput["laneCounts"],
|
|
124
|
+
selectedElementId: string | null,
|
|
125
|
+
selectedElementIds: ReadonlySet<string>,
|
|
126
|
+
gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"],
|
|
127
|
+
): {
|
|
128
|
+
activeClip: TimelineElement | null;
|
|
129
|
+
activeId: string | null;
|
|
130
|
+
lanes: ReturnType<typeof getTimelinePropertyLanes>;
|
|
131
|
+
} {
|
|
132
|
+
const activeClip = resolveTrackKeyframeClip(
|
|
133
|
+
elements,
|
|
134
|
+
laneCounts,
|
|
135
|
+
selectedElementId,
|
|
136
|
+
selectedElementIds,
|
|
137
|
+
);
|
|
138
|
+
const activeId = activeClip ? elementId(activeClip) : null;
|
|
139
|
+
const lanes = activeClip
|
|
140
|
+
? getTimelinePropertyLanes(
|
|
141
|
+
gsapAnimations.get(elementId(activeClip)) ?? [],
|
|
142
|
+
activeClip.start,
|
|
143
|
+
activeClip.duration,
|
|
144
|
+
)
|
|
145
|
+
: [];
|
|
146
|
+
return { activeClip, activeId, lanes };
|
|
147
|
+
}
|
|
148
|
+
|
|
109
149
|
function keyframeTarget(
|
|
110
150
|
keyframe: ReturnType<typeof getTimelinePropertyLanes>[number]["keyframes"][number],
|
|
111
151
|
): TimelineKeyframeTarget {
|
|
@@ -167,6 +207,42 @@ function propertyItems(
|
|
|
167
207
|
return items;
|
|
168
208
|
}
|
|
169
209
|
|
|
210
|
+
/** A clip's lanes are visible when either the caret or the `∿` button opened it. */
|
|
211
|
+
function isRowOpen(
|
|
212
|
+
activeId: string | null,
|
|
213
|
+
expandedClipIds: ReadonlySet<string>,
|
|
214
|
+
expandedLaneOwnerIds: ReadonlySet<string>,
|
|
215
|
+
): boolean {
|
|
216
|
+
if (activeId === null) return false;
|
|
217
|
+
return expandedClipIds.has(activeId) || expandedLaneOwnerIds.has(activeId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A single automation-lane row, one level deeper than the track/group row that owns it. */
|
|
221
|
+
function buildLaneRow(
|
|
222
|
+
track: number,
|
|
223
|
+
logicalIndex: number,
|
|
224
|
+
activeId: string,
|
|
225
|
+
activeClip: TimelineElement,
|
|
226
|
+
lane: ReturnType<typeof getTimelinePropertyLanes>[number],
|
|
227
|
+
level: 2 | 3,
|
|
228
|
+
parentId: string,
|
|
229
|
+
): TimelineLogicalRow {
|
|
230
|
+
const laneRowId = timelinePropertyRowId(activeId, lane.group);
|
|
231
|
+
return {
|
|
232
|
+
id: laneRowId,
|
|
233
|
+
kind: "row",
|
|
234
|
+
physicalTrackKey: track,
|
|
235
|
+
logicalIndex,
|
|
236
|
+
level,
|
|
237
|
+
parentId,
|
|
238
|
+
elementId: activeId,
|
|
239
|
+
expandable: false,
|
|
240
|
+
expanded: false,
|
|
241
|
+
propertyGroup: lane.group,
|
|
242
|
+
items: propertyItems(laneRowId, activeClip, lane.keyframes),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
170
246
|
/** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */
|
|
171
247
|
export function buildTimelineLogicalRows({
|
|
172
248
|
tracks,
|
|
@@ -175,57 +251,98 @@ export function buildTimelineLogicalRows({
|
|
|
175
251
|
selectedElementId,
|
|
176
252
|
selectedElementIds,
|
|
177
253
|
expandedClipIds,
|
|
254
|
+
expandedGroupIds,
|
|
255
|
+
expandedLaneOwnerIds,
|
|
256
|
+
groups,
|
|
257
|
+
trackGroupOf,
|
|
178
258
|
gsapAnimations,
|
|
179
259
|
}: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] {
|
|
180
260
|
const trackMap = new Map(tracks);
|
|
261
|
+
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
|
|
181
262
|
const rows: TimelineLogicalRow[] = [];
|
|
182
|
-
|
|
263
|
+
|
|
264
|
+
// A real track's own row (level 1 ungrouped, level 2 under a group) plus,
|
|
265
|
+
// when its clip's lanes are open, the lane rows one level deeper.
|
|
266
|
+
function emitTrack(track: number, level: 1 | 2, parentId: string | null): void {
|
|
183
267
|
const elements = trackMap.get(track) ?? [];
|
|
184
268
|
const trackId = timelineTrackRowId(track);
|
|
185
|
-
const activeClip =
|
|
269
|
+
const { activeClip, activeId, lanes } = resolveActiveTrackClip(
|
|
186
270
|
elements,
|
|
187
271
|
laneCounts,
|
|
188
272
|
selectedElementId,
|
|
189
273
|
selectedElementIds,
|
|
274
|
+
gsapAnimations,
|
|
190
275
|
);
|
|
191
|
-
const
|
|
192
|
-
const lanes = activeClip
|
|
193
|
-
? getTimelinePropertyLanes(
|
|
194
|
-
gsapAnimations.get(elementId(activeClip)) ?? [],
|
|
195
|
-
activeClip.start,
|
|
196
|
-
activeClip.duration,
|
|
197
|
-
)
|
|
198
|
-
: [];
|
|
199
|
-
const expanded = activeId !== null && expandedClipIds.has(activeId) && lanes.length > 0;
|
|
276
|
+
const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0;
|
|
200
277
|
rows.push({
|
|
201
278
|
id: trackId,
|
|
202
279
|
kind: "row",
|
|
203
280
|
physicalTrackKey: track,
|
|
204
281
|
logicalIndex: rows.length,
|
|
205
|
-
level
|
|
206
|
-
parentId
|
|
282
|
+
level,
|
|
283
|
+
parentId,
|
|
207
284
|
elementId: activeId,
|
|
208
285
|
expandable: lanes.length > 0,
|
|
209
286
|
expanded,
|
|
210
287
|
items: clipItems(trackId, elements),
|
|
211
288
|
});
|
|
212
|
-
if (!expanded || !activeClip)
|
|
289
|
+
if (!expanded || !activeClip || !activeId) return;
|
|
213
290
|
for (const lane of lanes) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
291
|
+
rows.push(
|
|
292
|
+
buildLaneRow(track, rows.length, activeId, activeClip, lane, level === 1 ? 2 : 3, trackId),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// A group's own row (level 1) plus, when its `∿` is open, its own
|
|
298
|
+
// automation-lane rows (level 2) — structural content deferred to whatever
|
|
299
|
+
// step wires group automation editing; this reserves the rows and their
|
|
300
|
+
// count.
|
|
301
|
+
function emitGroup(group: TimelineTrackGroupInfo): void {
|
|
302
|
+
const groupRowId = timelineGroupRowId(group.id);
|
|
303
|
+
const groupExpanded = expandedGroupIds.has(group.id);
|
|
304
|
+
rows.push({
|
|
305
|
+
id: groupRowId,
|
|
306
|
+
kind: "row",
|
|
307
|
+
physicalTrackKey: group.anchorKey,
|
|
308
|
+
logicalIndex: rows.length,
|
|
309
|
+
level: 1,
|
|
310
|
+
parentId: null,
|
|
311
|
+
elementId: null,
|
|
312
|
+
groupId: group.id,
|
|
313
|
+
expandable: group.memberTracks.length > 0,
|
|
314
|
+
expanded: groupExpanded,
|
|
315
|
+
items: [],
|
|
316
|
+
});
|
|
317
|
+
if (expandedLaneOwnerIds.has(group.id)) {
|
|
318
|
+
const memberElements = group.memberTracks.flatMap((track) => trackMap.get(track) ?? []);
|
|
319
|
+
for (const laneGroup of groupAutomationLanes(memberElements)) {
|
|
320
|
+
rows.push({
|
|
321
|
+
id: `${groupRowId}::${laneGroup.key}`,
|
|
322
|
+
kind: "row",
|
|
323
|
+
physicalTrackKey: group.anchorKey,
|
|
324
|
+
logicalIndex: rows.length,
|
|
325
|
+
level: 2,
|
|
326
|
+
parentId: groupRowId,
|
|
327
|
+
elementId: null,
|
|
328
|
+
expandable: false,
|
|
329
|
+
expanded: false,
|
|
330
|
+
items: [],
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (!groupExpanded) return;
|
|
335
|
+
for (const track of group.memberTracks) emitTrack(track, 2, groupRowId);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const key of displayTrackOrder) {
|
|
339
|
+
const group = groupByAnchor.get(key);
|
|
340
|
+
if (group) {
|
|
341
|
+
emitGroup(group);
|
|
342
|
+
continue;
|
|
228
343
|
}
|
|
344
|
+
if (trackGroupOf.has(key)) continue; // emitted above, under its group
|
|
345
|
+
emitTrack(key, 1, null);
|
|
229
346
|
}
|
|
230
347
|
return rows;
|
|
231
348
|
}
|