@hyperframes/studio 0.8.55 → 0.8.56
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-BjAGc2ID.js → hyperframes-player-k8LzMtXX.js} +1 -1
- package/dist/assets/{index-TcBZyzNA.js → index-BlkJ_Axe.js} +191 -191
- package/dist/assets/{index-DaNpNFJC.js → index-CxR7F2YQ.js} +1 -1
- package/dist/assets/{index-BEIRytvV.js → index-DFlILHJe.js} +1 -1
- package/dist/assets/{index-DrHQAuGO.css → index-ZmBtVrEm.css} +1 -1
- package/dist/index.d.ts +16 -8
- package/dist/index.html +2 -2
- package/dist/index.js +3111 -2998
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +14 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +59 -50
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -40
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
- package/src/components/editor/PropertyPanel.tsx +4 -3
- package/src/components/editor/SnapToolbar.test.tsx +1 -0
- package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
- package/src/components/editor/previewReadOnlyContext.tsx +59 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
- package/src/components/editor/useDomEditNudge.ts +9 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
- package/src/components/editor/useOffCanvasIndicators.ts +50 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +15 -8
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +128 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
- package/src/components/nle/usePreviewBlockDrop.ts +3 -1
- package/src/hooks/appHotkeysDispatch.ts +16 -1
- package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
- package/src/hooks/timelineAudioGroupCreate.ts +3 -4
- package/src/hooks/timelineTrackVisibility.ts +7 -8
- package/src/hooks/useAppHotkeys.test.ts +71 -3
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -0
- package/src/hooks/useDomEditCommits.test.tsx +1 -0
- package/src/hooks/useDomEditCommits.ts +3 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useDomEditWiring.ts +40 -40
- package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
- package/src/hooks/useDomGeometryCommits.ts +26 -20
- package/src/hooks/useGestureCommit.test.tsx +29 -0
- package/src/hooks/useGestureCommit.ts +23 -0
- package/src/hooks/useInlineTextEdit.test.tsx +23 -2
- package/src/hooks/useInlineTextEdit.ts +11 -2
- package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
- package/src/hooks/useTimelineAssetDropOps.ts +1 -6
- package/src/hooks/useTimelineEditing.test.tsx +10 -7
- package/src/hooks/useTimelineEditing.ts +2 -7
- package/src/player/components/Timeline.tsx +22 -23
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +34 -30
- package/src/player/components/timelineTrackDisplay.ts +1 -4
- package/src/player/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
- package/src/player/store/playerStore.ts +0 -4
- package/src/player/store/timelineElement.ts +2 -4
- package/src/player/store/timelineResetState.ts +0 -1
- package/src/styles/hex-baseline.json +3 -4
- package/src/styles/studio.css +7 -7
- package/src/styles/theme.css +29 -0
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- package/src/player/hooks/useTimelineRowElements.ts +0 -17
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
|
|
6
7
|
import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
|
|
7
8
|
|
|
8
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -20,7 +21,7 @@ function heading(text = "Motion Playground"): HTMLElement {
|
|
|
20
21
|
return element;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function mount(onCommit = vi.fn(), onPause = vi.fn()) {
|
|
24
|
+
function mount(onCommit = vi.fn(), onPause = vi.fn(), readOnly = false) {
|
|
24
25
|
const controls: { current: InlineTextEditControls | null } = { current: null };
|
|
25
26
|
function Probe() {
|
|
26
27
|
controls.current = useInlineTextEdit({ onCommit, onPause });
|
|
@@ -29,7 +30,13 @@ function mount(onCommit = vi.fn(), onPause = vi.fn()) {
|
|
|
29
30
|
const host = document.createElement("div");
|
|
30
31
|
document.body.append(host);
|
|
31
32
|
const root = createRoot(host);
|
|
32
|
-
act(() =>
|
|
33
|
+
act(() =>
|
|
34
|
+
root.render(
|
|
35
|
+
<PreviewReadOnlyProvider readOnly={readOnly}>
|
|
36
|
+
<Probe />
|
|
37
|
+
</PreviewReadOnlyProvider>,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
33
40
|
return { controls: () => controls.current!, root, onCommit, onPause };
|
|
34
41
|
}
|
|
35
42
|
|
|
@@ -552,4 +559,18 @@ describe("useInlineTextEdit with styled runs", () => {
|
|
|
552
559
|
expect(insertText).toHaveBeenCalledWith("insertText", false, "plain words");
|
|
553
560
|
act(() => root.unmount());
|
|
554
561
|
});
|
|
562
|
+
|
|
563
|
+
it("refuses to open an edit while the preview is read-only", () => {
|
|
564
|
+
const element = heading();
|
|
565
|
+
const { controls, root, onCommit, onPause } = mount(vi.fn(), vi.fn(), true);
|
|
566
|
+
let opened = true;
|
|
567
|
+
act(() => {
|
|
568
|
+
opened = controls().start(element);
|
|
569
|
+
});
|
|
570
|
+
expect(opened).toBe(false);
|
|
571
|
+
expect(element.hasAttribute("contenteditable")).toBe(false);
|
|
572
|
+
expect(onPause).not.toHaveBeenCalled();
|
|
573
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
574
|
+
act(() => root.unmount());
|
|
575
|
+
});
|
|
555
576
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
|
|
3
|
+
import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Editing an element's text where it sits, in the composition itself.
|
|
@@ -89,6 +90,7 @@ export function useInlineTextEdit({
|
|
|
89
90
|
/** Stop playback, so the element is not animating under the caret. */
|
|
90
91
|
onPause?: () => void;
|
|
91
92
|
}): InlineTextEditControls {
|
|
93
|
+
const readOnly = usePreviewReadOnly();
|
|
92
94
|
const [session, setSession] = useState<InlineTextEditSession | null>(null);
|
|
93
95
|
// The teardown reads this rather than the state, so an exit path that runs
|
|
94
96
|
// before React re-renders still sees the element it has to clean up.
|
|
@@ -123,7 +125,7 @@ export function useInlineTextEdit({
|
|
|
123
125
|
|
|
124
126
|
const start = useCallback(
|
|
125
127
|
(element: HTMLElement, caretAt?: { x: number; y: number }): boolean => {
|
|
126
|
-
if (openRef.current) return false;
|
|
128
|
+
if (openRef.current || readOnly) return false;
|
|
127
129
|
|
|
128
130
|
const open = {
|
|
129
131
|
element,
|
|
@@ -156,7 +158,7 @@ export function useInlineTextEdit({
|
|
|
156
158
|
framesRef.current = raf ?? null;
|
|
157
159
|
return true;
|
|
158
160
|
},
|
|
159
|
-
[onPause],
|
|
161
|
+
[onPause, readOnly],
|
|
160
162
|
);
|
|
161
163
|
|
|
162
164
|
const commit = useCallback(() => {
|
|
@@ -179,6 +181,13 @@ export function useInlineTextEdit({
|
|
|
179
181
|
teardown();
|
|
180
182
|
}, [teardown]);
|
|
181
183
|
|
|
184
|
+
// Read-only can be enabled while an edit is already open. Close that
|
|
185
|
+
// session as a cancellation so the preview cannot keep committing through
|
|
186
|
+
// the now-disabled editing surface.
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (readOnly && openRef.current) cancel();
|
|
189
|
+
}, [cancel, readOnly]);
|
|
190
|
+
|
|
182
191
|
// The keys belong to the element, not to the document: the element lives in
|
|
183
192
|
// the preview's own document, so a listener on Studio's would never see them.
|
|
184
193
|
useEffect(() => {
|
|
@@ -103,7 +103,7 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
103
103
|
expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it("measures the insert row against
|
|
106
|
+
it("measures the insert row against every manifest timeline row", async () => {
|
|
107
107
|
const clip = (id: string, track: number): TimelineElement => ({
|
|
108
108
|
id,
|
|
109
109
|
key: id,
|
|
@@ -123,7 +123,6 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
123
123
|
}),
|
|
124
124
|
"</main>",
|
|
125
125
|
].join("\n");
|
|
126
|
-
usePlayerStore.getState().setTopLevelIds(new Set(["a", "b"]));
|
|
127
126
|
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
128
127
|
const getDrop = renderDropHook(source, writeProjectFile, [
|
|
129
128
|
clip("a", 0),
|
|
@@ -136,8 +135,8 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
136
135
|
});
|
|
137
136
|
|
|
138
137
|
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
139
|
-
expect(written).toContain('id="b" data-start="0" data-track-index="
|
|
140
|
-
expect(written).toContain('id="hidden" data-start="0" data-track-index="
|
|
138
|
+
expect(written).toContain('id="b" data-start="0" data-track-index="3"');
|
|
139
|
+
expect(written).toContain('id="hidden" data-start="0" data-track-index="2"');
|
|
141
140
|
expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
|
|
142
141
|
});
|
|
143
142
|
|
|
@@ -25,8 +25,6 @@ import { commitTimelineCompositionInsertion } from "../utils/timelineComposition
|
|
|
25
25
|
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
26
26
|
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
27
27
|
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
28
|
-
import { selectTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
29
|
-
import { usePlayerStore } from "../player/store/playerStore";
|
|
30
28
|
|
|
31
29
|
/** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
|
|
32
30
|
function fileDropPlacement(
|
|
@@ -127,10 +125,7 @@ export function useTimelineAssetDropOps({
|
|
|
127
125
|
const { source: sourceWithRoom, track } = resolveDropTrack({
|
|
128
126
|
source: originalContent,
|
|
129
127
|
// insertRow counts the rows the timeline shows, so plan against those.
|
|
130
|
-
elements:
|
|
131
|
-
relevantElements,
|
|
132
|
-
usePlayerStore.getState().topLevelIds,
|
|
133
|
-
),
|
|
128
|
+
elements: relevantElements,
|
|
134
129
|
placement,
|
|
135
130
|
dropped: {
|
|
136
131
|
id: newId,
|
|
@@ -1561,21 +1561,24 @@ describe("useTimelineEditing: canEdit gate", () => {
|
|
|
1561
1561
|
unmount();
|
|
1562
1562
|
});
|
|
1563
1563
|
|
|
1564
|
-
it("resolves toggle-track-hidden against the
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1564
|
+
it("resolves toggle-track-hidden against the store-owned timeline rows", async () => {
|
|
1565
|
+
// The visibility hook reads the single store-owned row source. The editing
|
|
1566
|
+
// guard must receive that same source so a blocked row cannot fall through
|
|
1567
|
+
// to the write path.
|
|
1568
1568
|
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
|
1569
1569
|
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
|
1570
1570
|
usePlayerStore.getState().setElements([clip]);
|
|
1571
1571
|
const showToast = vi.fn();
|
|
1572
1572
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
1573
|
+
const canEdit = vi.fn((element: TimelineElement) =>
|
|
1574
|
+
element.id === "clip" ? { blocked: true as const, reason: "Reserved by an agent" } : true,
|
|
1575
|
+
);
|
|
1573
1576
|
let hook: ReturnType<typeof useTimelineEditing> | null = null;
|
|
1574
1577
|
function Harness() {
|
|
1575
1578
|
hook = useTimelineEditing({
|
|
1576
1579
|
projectId: "p1",
|
|
1577
1580
|
activeCompPath: "index.html",
|
|
1578
|
-
timelineElements: [],
|
|
1581
|
+
timelineElements: [clip],
|
|
1579
1582
|
showToast,
|
|
1580
1583
|
writeProjectFile,
|
|
1581
1584
|
recordEdit: vi.fn(),
|
|
@@ -1583,8 +1586,7 @@ describe("useTimelineEditing: canEdit gate", () => {
|
|
|
1583
1586
|
previewIframeRef: { current: iframe },
|
|
1584
1587
|
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
1585
1588
|
uploadProjectFiles: vi.fn(),
|
|
1586
|
-
canEdit
|
|
1587
|
-
element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
|
|
1589
|
+
canEdit,
|
|
1588
1590
|
});
|
|
1589
1591
|
return null;
|
|
1590
1592
|
}
|
|
@@ -1596,6 +1598,7 @@ describe("useTimelineEditing: canEdit gate", () => {
|
|
|
1596
1598
|
await flushAsyncWork();
|
|
1597
1599
|
});
|
|
1598
1600
|
|
|
1601
|
+
expect(canEdit).toHaveBeenCalledWith(clip);
|
|
1599
1602
|
expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
|
|
1600
1603
|
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
1601
1604
|
unmount();
|
|
@@ -25,7 +25,6 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
|
25
25
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
26
26
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
27
27
|
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
28
|
-
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
29
28
|
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
|
|
30
29
|
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
|
31
30
|
import {
|
|
@@ -77,10 +76,6 @@ export function useTimelineEditing({
|
|
|
77
76
|
const checkEditable = useTimelineEditGate(canEdit, showToast);
|
|
78
77
|
const checkEditableRef = useRef(checkEditable);
|
|
79
78
|
checkEditableRef.current = checkEditable;
|
|
80
|
-
// Same expanded-row source the hide handlers themselves resolve against
|
|
81
|
-
// (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
|
|
82
|
-
// only exists here, not in the raw store list canEdit would otherwise miss.
|
|
83
|
-
const timelineRowElements = useTimelineRowElements();
|
|
84
79
|
const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
|
|
85
80
|
// Refuses (no call, no write, no history entry) when any target is
|
|
86
81
|
// blocked; otherwise runs fn as before. Cached by fn identity — like
|
|
@@ -514,14 +509,14 @@ export function useTimelineEditing({
|
|
|
514
509
|
handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
|
|
515
510
|
handleToggleTrackHidden: track(
|
|
516
511
|
guard(
|
|
517
|
-
(trackIndex) =>
|
|
512
|
+
(trackIndex) => timelineElements.filter((el) => el.track === trackIndex),
|
|
518
513
|
handleToggleTrackHidden,
|
|
519
514
|
),
|
|
520
515
|
),
|
|
521
516
|
handleToggleElementHidden: track(
|
|
522
517
|
guard((elementKey) => {
|
|
523
518
|
const keys = new Set(Array.isArray(elementKey) ? elementKey : [elementKey]);
|
|
524
|
-
return
|
|
519
|
+
return timelineElements.filter((el) => keys.has(el.key ?? el.id));
|
|
525
520
|
}, handleToggleElementHidden),
|
|
526
521
|
),
|
|
527
522
|
handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useRef, useMemo, useCallback, useState, memo } from "react";
|
|
2
2
|
import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
|
3
3
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
4
|
-
import { useTimelineRowElements } from "../hooks/useTimelineRowElements";
|
|
5
4
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
6
5
|
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
7
6
|
import { usePublishRangeSelection } from "./usePublishRangeSelection";
|
|
@@ -113,7 +112,7 @@ export const Timeline = memo(function Timeline({
|
|
|
113
112
|
const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
|
|
114
113
|
useMusicBeatAnalysis();
|
|
115
114
|
const rawElements = usePlayerStore((s) => s.elements);
|
|
116
|
-
const
|
|
115
|
+
const timelineElements = rawElements;
|
|
117
116
|
const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
|
|
118
117
|
const duration = usePlayerStore((s) => s.duration);
|
|
119
118
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
@@ -123,8 +122,8 @@ export const Timeline = memo(function Timeline({
|
|
|
123
122
|
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
124
123
|
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
|
125
124
|
const labelMode = useMemo(
|
|
126
|
-
() => timelineNeedsLabelColumn(gsapAnimations,
|
|
127
|
-
[gsapAnimations,
|
|
125
|
+
() => timelineNeedsLabelColumn(gsapAnimations, timelineElements),
|
|
126
|
+
[gsapAnimations, timelineElements],
|
|
128
127
|
);
|
|
129
128
|
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
130
129
|
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
@@ -163,13 +162,13 @@ export const Timeline = memo(function Timeline({
|
|
|
163
162
|
groups,
|
|
164
163
|
trackGroupOf,
|
|
165
164
|
} = useTimelineTrackLayout(
|
|
166
|
-
|
|
165
|
+
timelineElements,
|
|
167
166
|
gsapAnimations,
|
|
168
167
|
selectedElementId,
|
|
169
168
|
selectedElementIds,
|
|
170
169
|
);
|
|
171
|
-
const
|
|
172
|
-
|
|
170
|
+
const timelineElementsRef = useRef(timelineElements);
|
|
171
|
+
timelineElementsRef.current = timelineElements; // oxlint-disable-line react/refs -- event handlers read the latest elements
|
|
173
172
|
const ppsRef = useRef(100);
|
|
174
173
|
const durationRef = useRef(effectiveDuration);
|
|
175
174
|
durationRef.current = effectiveDuration; // oxlint-disable-line react/refs -- event handlers read the latest duration
|
|
@@ -198,7 +197,7 @@ export const Timeline = memo(function Timeline({
|
|
|
198
197
|
onCompositionDrop,
|
|
199
198
|
});
|
|
200
199
|
const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
|
|
201
|
-
expandedElementsRef,
|
|
200
|
+
expandedElementsRef: timelineElementsRef,
|
|
202
201
|
});
|
|
203
202
|
const {
|
|
204
203
|
gapMenuModel,
|
|
@@ -210,7 +209,7 @@ export const Timeline = memo(function Timeline({
|
|
|
210
209
|
closeAllTrackGaps,
|
|
211
210
|
} = useTrackGapMenu({
|
|
212
211
|
tracks,
|
|
213
|
-
expandedElementsRef,
|
|
212
|
+
expandedElementsRef: timelineElementsRef,
|
|
214
213
|
trackOrderRef,
|
|
215
214
|
onMoveElement: pinnedOnMoveElement,
|
|
216
215
|
onMoveElements: pinnedOnMoveElements,
|
|
@@ -259,14 +258,14 @@ export const Timeline = memo(function Timeline({
|
|
|
259
258
|
resizingClip?.groupPreview?.map((change) => change.key) ??
|
|
260
259
|
(resizingClip ? [getTimelineElementIdentity(resizingClip.element)] : undefined);
|
|
261
260
|
const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
|
|
262
|
-
totalClipCount:
|
|
261
|
+
totalClipCount: timelineElements.length,
|
|
263
262
|
totalRowCount: displayLayout.displayTrackOrder.length,
|
|
264
263
|
zoomMode,
|
|
265
264
|
});
|
|
266
265
|
const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
|
|
267
266
|
useTimelineScrollViewport(scrollRef, [
|
|
268
267
|
timelineReady,
|
|
269
|
-
|
|
268
|
+
timelineElements.length,
|
|
270
269
|
displayLayout.totalH,
|
|
271
270
|
]);
|
|
272
271
|
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
|
|
@@ -279,7 +278,7 @@ export const Timeline = memo(function Timeline({
|
|
|
279
278
|
fitPpsRef,
|
|
280
279
|
draggedClip,
|
|
281
280
|
resizingClip,
|
|
282
|
-
expandedElements,
|
|
281
|
+
expandedElements: timelineElements,
|
|
283
282
|
isDragging,
|
|
284
283
|
scrollRef,
|
|
285
284
|
lastScrollLeftRef,
|
|
@@ -295,7 +294,7 @@ export const Timeline = memo(function Timeline({
|
|
|
295
294
|
groups,
|
|
296
295
|
trackGroupOf,
|
|
297
296
|
gsapAnimations,
|
|
298
|
-
elements:
|
|
297
|
+
elements: timelineElements,
|
|
299
298
|
pixelsPerSecond: pps,
|
|
300
299
|
contentOrigin,
|
|
301
300
|
allowHorizontal: zoomMode === "manual",
|
|
@@ -312,7 +311,7 @@ export const Timeline = memo(function Timeline({
|
|
|
312
311
|
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
|
|
313
312
|
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
|
|
314
313
|
useTimelineKeyframeHandlers({
|
|
315
|
-
expandedElements,
|
|
314
|
+
expandedElements: timelineElements,
|
|
316
315
|
keyframeCache,
|
|
317
316
|
onSelectElement,
|
|
318
317
|
onSeek,
|
|
@@ -343,14 +342,14 @@ export const Timeline = memo(function Timeline({
|
|
|
343
342
|
scrollRef,
|
|
344
343
|
currentTime,
|
|
345
344
|
clipStateVersion: renderTimeRange,
|
|
346
|
-
elementStateVersion:
|
|
345
|
+
elementStateVersion: timelineElements,
|
|
347
346
|
});
|
|
348
347
|
const laneGapStrips = useTimelineGapHighlights({
|
|
349
348
|
gapHighlight,
|
|
350
349
|
tracks,
|
|
351
350
|
selectedElementId,
|
|
352
351
|
selectedElementIds,
|
|
353
|
-
expandedElements,
|
|
352
|
+
expandedElements: timelineElements,
|
|
354
353
|
dragActive: draggedClip?.started === true || resizingClip != null,
|
|
355
354
|
displayDuration,
|
|
356
355
|
});
|
|
@@ -371,7 +370,7 @@ export const Timeline = memo(function Timeline({
|
|
|
371
370
|
effectiveDuration,
|
|
372
371
|
pps,
|
|
373
372
|
timelineReady,
|
|
374
|
-
elementsLength:
|
|
373
|
+
elementsLength: timelineElements.length,
|
|
375
374
|
setZoomMode,
|
|
376
375
|
setManualZoomPercent,
|
|
377
376
|
onSeek,
|
|
@@ -407,7 +406,7 @@ export const Timeline = memo(function Timeline({
|
|
|
407
406
|
dragScrollRaf,
|
|
408
407
|
isDragging,
|
|
409
408
|
setShowPopover,
|
|
410
|
-
elementsRef:
|
|
409
|
+
elementsRef: timelineElementsRef,
|
|
411
410
|
clipIndex,
|
|
412
411
|
rowGeometryRef,
|
|
413
412
|
onSelectElement,
|
|
@@ -417,7 +416,7 @@ export const Timeline = memo(function Timeline({
|
|
|
417
416
|
usePublishRangeSelection(rangeSelection, onRangeSelect);
|
|
418
417
|
setRangeSelectionRef.current = setRangeSelection; // oxlint-disable-line react/refs -- stable ref consumed by useTimelineClipDrag
|
|
419
418
|
|
|
420
|
-
useTimelineSelectionLifecycle(
|
|
419
|
+
useTimelineSelectionLifecycle(timelineElements, selectedElementId, setShowPopover, () =>
|
|
421
420
|
setRangeSelection(null),
|
|
422
421
|
);
|
|
423
422
|
|
|
@@ -433,7 +432,7 @@ export const Timeline = memo(function Timeline({
|
|
|
433
432
|
[resizingClip],
|
|
434
433
|
);
|
|
435
434
|
|
|
436
|
-
if (!timelineReady ||
|
|
435
|
+
if (!timelineReady || timelineElements.length === 0) {
|
|
437
436
|
return (
|
|
438
437
|
<TimelineEmptyState
|
|
439
438
|
isDragOver={assetDrop.isDragOver}
|
|
@@ -449,7 +448,7 @@ export const Timeline = memo(function Timeline({
|
|
|
449
448
|
<div
|
|
450
449
|
ref={setContainerRef}
|
|
451
450
|
aria-label="Timeline track view"
|
|
452
|
-
data-timeline-element-count={
|
|
451
|
+
data-timeline-element-count={timelineElements.length}
|
|
453
452
|
className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
454
453
|
onMouseMove={updateRazorGuide}
|
|
455
454
|
onMouseLeave={clearRazorGuide}
|
|
@@ -566,8 +565,8 @@ export const Timeline = memo(function Timeline({
|
|
|
566
565
|
{activeTool === "razor" && razorGuideX !== null && <TimelineRazorGuide x={razorGuideX} />}
|
|
567
566
|
</div>
|
|
568
567
|
<TimelineOverlays
|
|
569
|
-
elements={
|
|
570
|
-
elementsRef={
|
|
568
|
+
elements={timelineElements}
|
|
569
|
+
elementsRef={timelineElementsRef}
|
|
571
570
|
theme={theme}
|
|
572
571
|
showShortcutHint={showShortcutHint}
|
|
573
572
|
showPopover={showPopover}
|
|
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import type { TimelineElement } from "../store/playerStore";
|
|
7
7
|
import { TimelineClip } from "./TimelineClip";
|
|
8
8
|
import type { TimelineEditCapabilities } from "./timelineEditing";
|
|
9
|
+
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
|
|
9
10
|
|
|
10
11
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
11
12
|
configurable: true,
|
|
@@ -27,11 +28,13 @@ function renderClip({
|
|
|
27
28
|
pps = 100,
|
|
28
29
|
isSelected = false,
|
|
29
30
|
hasCustomContent = true,
|
|
31
|
+
theme = defaultTimelineTheme,
|
|
30
32
|
}: {
|
|
31
33
|
element: TimelineElement;
|
|
32
34
|
pps?: number;
|
|
33
35
|
isSelected?: boolean;
|
|
34
36
|
hasCustomContent?: boolean;
|
|
37
|
+
theme?: TimelineTheme;
|
|
35
38
|
}) {
|
|
36
39
|
const host = document.createElement("div");
|
|
37
40
|
document.body.append(host);
|
|
@@ -48,6 +51,7 @@ function renderClip({
|
|
|
48
51
|
isHovered={false}
|
|
49
52
|
hasCustomContent={hasCustomContent}
|
|
50
53
|
capabilities={capabilities}
|
|
54
|
+
theme={theme}
|
|
51
55
|
isComposition={false}
|
|
52
56
|
onHoverStart={vi.fn()}
|
|
53
57
|
onHoverEnd={vi.fn()}
|
|
@@ -115,6 +119,54 @@ describe("TimelineClip", () => {
|
|
|
115
119
|
act(() => root.unmount());
|
|
116
120
|
});
|
|
117
121
|
|
|
122
|
+
it("passes clip and handle theme tokens to the rendered elements", () => {
|
|
123
|
+
const theme: TimelineTheme = {
|
|
124
|
+
...defaultTimelineTheme,
|
|
125
|
+
clipBackground: "var(--test-clip-bg)",
|
|
126
|
+
clipBackgroundActive: "var(--test-clip-bg-active)",
|
|
127
|
+
clipBackgroundHover: "var(--test-clip-bg-hover)",
|
|
128
|
+
clipBackgroundDragging: "var(--test-clip-bg-dragging)",
|
|
129
|
+
clipBorder: "var(--test-clip-border)",
|
|
130
|
+
clipBorderHover: "var(--test-clip-border-hover)",
|
|
131
|
+
clipBorderActive: "var(--test-clip-border-active)",
|
|
132
|
+
handleColor: "var(--test-handle)",
|
|
133
|
+
};
|
|
134
|
+
const { host, root } = renderClip({
|
|
135
|
+
element: { id: "themed", label: "Themed", tag: "div", start: 0, duration: 1, track: 0 },
|
|
136
|
+
isSelected: true,
|
|
137
|
+
theme,
|
|
138
|
+
});
|
|
139
|
+
const clip = host.querySelector<HTMLElement>(".timeline-clip")!;
|
|
140
|
+
expect(clip.style.getPropertyValue("--clip-bg")).toBe("var(--test-clip-bg)");
|
|
141
|
+
expect(clip.style.getPropertyValue("--clip-border-active")).toBe(
|
|
142
|
+
"var(--test-clip-border-active)",
|
|
143
|
+
);
|
|
144
|
+
expect(clip.style.getPropertyValue("--clip-handle")).toBe("var(--test-handle)");
|
|
145
|
+
expect(clip.querySelector<HTMLElement>(".timeline-clip__handle-bar")?.style.background).toBe(
|
|
146
|
+
"var(--clip-handle)",
|
|
147
|
+
);
|
|
148
|
+
act(() => root.unmount());
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("keeps default token references off the properties they resolve", () => {
|
|
152
|
+
const { host, root } = renderClip({
|
|
153
|
+
element: {
|
|
154
|
+
id: "default-theme",
|
|
155
|
+
label: "Default",
|
|
156
|
+
tag: "div",
|
|
157
|
+
start: 0,
|
|
158
|
+
duration: 1,
|
|
159
|
+
track: 0,
|
|
160
|
+
},
|
|
161
|
+
isSelected: true,
|
|
162
|
+
});
|
|
163
|
+
const clip = host.querySelector<HTMLElement>(".timeline-clip")!;
|
|
164
|
+
expect(clip.style.getPropertyValue("--clip-bg")).toBe("var(--timeline-clip-bg)");
|
|
165
|
+
expect(clip.style.getPropertyValue("--clip-bg")).not.toBe("var(--clip-bg)");
|
|
166
|
+
expect(clip.style.getPropertyValue("--clip-handle")).toBe("var(--timeline-handle)");
|
|
167
|
+
act(() => root.unmount());
|
|
168
|
+
});
|
|
169
|
+
|
|
118
170
|
it("is a roving native button with explicit selection semantics", () => {
|
|
119
171
|
const { host, onClick, root } = renderClip({
|
|
120
172
|
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 2, track: 0 },
|
|
@@ -64,6 +64,16 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
64
64
|
const showDefaultText = !hasCustomContent && (widthPx >= 40 || isSelected);
|
|
65
65
|
const startLabel = el.start.toFixed(1);
|
|
66
66
|
const endLabel = (el.start + el.duration).toFixed(1);
|
|
67
|
+
const themeVariables = {
|
|
68
|
+
"--clip-bg": theme.clipBackground,
|
|
69
|
+
"--clip-bg-active": theme.clipBackgroundActive,
|
|
70
|
+
"--clip-bg-hover": theme.clipBackgroundHover,
|
|
71
|
+
"--clip-bg-dragging": theme.clipBackgroundDragging,
|
|
72
|
+
"--clip-border": theme.clipBorder,
|
|
73
|
+
"--clip-border-hover": theme.clipBorderHover,
|
|
74
|
+
"--clip-border-active": theme.clipBorderActive,
|
|
75
|
+
"--clip-handle": theme.handleColor,
|
|
76
|
+
} as CSSProperties;
|
|
67
77
|
const clipClassName = [
|
|
68
78
|
"timeline-clip",
|
|
69
79
|
"absolute",
|
|
@@ -81,6 +91,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
81
91
|
top: clipY,
|
|
82
92
|
...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
|
|
83
93
|
borderRadius: theme.clipRadius,
|
|
94
|
+
...themeVariables,
|
|
84
95
|
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
|
85
96
|
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
|
|
86
97
|
cursor: "default",
|
|
@@ -144,7 +155,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
144
155
|
bottom: 6,
|
|
145
156
|
width: 2,
|
|
146
157
|
borderRadius: 1,
|
|
147
|
-
background: "
|
|
158
|
+
background: "var(--clip-handle)",
|
|
148
159
|
opacity: handleOpacity * 0.6,
|
|
149
160
|
}}
|
|
150
161
|
/>
|
|
@@ -174,7 +185,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
174
185
|
bottom: 6,
|
|
175
186
|
width: 2,
|
|
176
187
|
borderRadius: 1,
|
|
177
|
-
background: "
|
|
188
|
+
background: "var(--clip-handle)",
|
|
178
189
|
opacity: handleOpacity * 0.6,
|
|
179
190
|
}}
|
|
180
191
|
/>
|
|
@@ -5,7 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
5
5
|
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
8
|
-
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
|
8
|
+
import { TimelineTrackHeader, gutterFill } from "./TimelineTrackHeader";
|
|
9
9
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
10
10
|
import { type TimelineElement } from "../store/playerStore";
|
|
11
11
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
@@ -794,10 +794,6 @@ describe("TimelineTrackHeader", () => {
|
|
|
794
794
|
});
|
|
795
795
|
expect(header()?.style.paddingLeft).toBe("14px");
|
|
796
796
|
expect(header()?.style.borderLeft).toContain("2px");
|
|
797
|
-
// And a lighter gutter, so the row reads as sitting INSIDE its group
|
|
798
|
-
// rather than beside it. Overlaid on the theme's own fill rather than a
|
|
799
|
-
// hard-coded colour, so it follows whatever the gutter is.
|
|
800
|
-
expect(header()?.style.background).toContain("linear-gradient");
|
|
801
797
|
act(() => view.root.unmount());
|
|
802
798
|
});
|
|
803
799
|
|
|
@@ -912,3 +908,28 @@ describe("TimelineTrackHeader", () => {
|
|
|
912
908
|
});
|
|
913
909
|
});
|
|
914
910
|
});
|
|
911
|
+
|
|
912
|
+
// A host themes the timeline by overriding --timeline-* on theme.css, so the
|
|
913
|
+
// rendered gutter must carry the CSS variable, not a baked-in colour.
|
|
914
|
+
describe("theming", () => {
|
|
915
|
+
it("reads the gutter background and border from timeline theme tokens", () => {
|
|
916
|
+
const view = renderHeader({});
|
|
917
|
+
const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
|
|
918
|
+
expect(header?.style.background).toBe(defaultTimelineTheme.gutterBackground);
|
|
919
|
+
expect(header?.style.background).toContain("var(--timeline-gutter-bg)");
|
|
920
|
+
expect(header?.style.borderRight).toContain(defaultTimelineTheme.gutterBorder);
|
|
921
|
+
act(() => view.root.unmount());
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
// happy-dom's `background` shorthand garbles a `var()` layer next to
|
|
925
|
+
// `linear-gradient(...)` (verified), so this checks the same value through
|
|
926
|
+
// the pure function instead of the broken CSSOM roundtrip.
|
|
927
|
+
it("overlays the group-member tint on the themed gutter, not a hard-coded colour", () => {
|
|
928
|
+
const filled = gutterFill(defaultTimelineTheme.gutterBackground, true);
|
|
929
|
+
expect(filled).toContain("linear-gradient");
|
|
930
|
+
expect(filled.endsWith(defaultTimelineTheme.gutterBackground)).toBe(true);
|
|
931
|
+
expect(gutterFill(defaultTimelineTheme.gutterBackground, false)).toBe(
|
|
932
|
+
defaultTimelineTheme.gutterBackground,
|
|
933
|
+
);
|
|
934
|
+
});
|
|
935
|
+
});
|
|
@@ -34,7 +34,7 @@ const GROUP_MEMBER_INDENT = 14;
|
|
|
34
34
|
const GROUP_MEMBER_TINT = "rgba(255,255,255,0.035)";
|
|
35
35
|
|
|
36
36
|
/** The gutter fill for a row, tinted when it belongs to a group. */
|
|
37
|
-
function gutterFill(base: string, isGroupMember: boolean): string {
|
|
37
|
+
export function gutterFill(base: string, isGroupMember: boolean): string {
|
|
38
38
|
return isGroupMember
|
|
39
39
|
? `linear-gradient(${GROUP_MEMBER_TINT}, ${GROUP_MEMBER_TINT}), ${base}`
|
|
40
40
|
: base;
|
|
@@ -75,11 +75,11 @@ describe("timeline motion styles", () => {
|
|
|
75
75
|
const bloomOverlayRule = expectRule(studioCss, ".timeline-clip::before");
|
|
76
76
|
const activeBloomOverlayRule = expectRule(studioCss, ".timeline-clip[data-active]::before");
|
|
77
77
|
|
|
78
|
-
expect(baseTimelineClipRule).toContain("background-color:
|
|
78
|
+
expect(baseTimelineClipRule).toContain("background-color: var(--clip-bg)");
|
|
79
79
|
expect(activeTimelineClipRule).not.toContain("background: linear-gradient");
|
|
80
|
-
expect(activeTimelineClipRule).toContain("border-color:
|
|
80
|
+
expect(activeTimelineClipRule).toContain("border-color: var(--clip-border-active)");
|
|
81
81
|
expect(activeTimelineClipRule).not.toContain("box-shadow");
|
|
82
|
-
expect(bloomOverlayRule).toContain("background:
|
|
82
|
+
expect(bloomOverlayRule).toContain("background: var(--clip-bg-active)");
|
|
83
83
|
expect(bloomOverlayRule).not.toContain("linear-gradient");
|
|
84
84
|
expect(bloomOverlayRule).toContain("opacity: 0");
|
|
85
85
|
expect(activeBloomOverlayRule).toContain("opacity: 1");
|
|
@@ -9,10 +9,10 @@ import { getTrackStyle } from "./timelineIcons";
|
|
|
9
9
|
describe("getTimelineTrackStyle", () => {
|
|
10
10
|
it("uses one neutral clip style for every timeline tag", () => {
|
|
11
11
|
const expectedStyle = {
|
|
12
|
-
clip: "
|
|
13
|
-
clipActive: "
|
|
14
|
-
accent: "
|
|
15
|
-
label: "
|
|
12
|
+
clip: "var(--timeline-track-clip-fill)",
|
|
13
|
+
clipActive: "var(--timeline-track-clip-active)",
|
|
14
|
+
accent: "var(--color-accent)",
|
|
15
|
+
label: "var(--timeline-track-label)",
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
expect(getTimelineTrackStyle("video")).toEqual(expectedStyle);
|