@hyperframes/studio 0.7.39 → 0.7.41
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/{index-CQHiecE7.js → index-BCG-TnJO.js} +1 -1
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-B9YvRJz1.js → index-DRrNf6k_.js} +195 -195
- package/dist/assets/{index-312a3Ceu.js → index-rnqIbAs_.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
- package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +34 -29
- package/dist/index.html +2 -2
- package/dist/index.js +2749 -2075
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -1
- package/src/components/StudioPreviewArea.tsx +6 -0
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/editor/DomEditOverlay.tsx +33 -47
- package/src/components/editor/PropertyPanel.test.ts +25 -0
- package/src/components/editor/PropertyPanel.tsx +33 -7
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +11 -0
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-0P10SwC_.css +0 -1
- /package/dist/{domEditingLayers-S6YOLVRG.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
const studioCss = readFileSync(new URL("../../styles/studio.css", import.meta.url), "utf8");
|
|
5
|
+
const timelineClipSource = readFileSync(new URL("./TimelineClip.tsx", import.meta.url), "utf8");
|
|
6
|
+
const playheadSource = readFileSync(new URL("./PlayheadIndicator.tsx", import.meta.url), "utf8");
|
|
7
|
+
|
|
8
|
+
const allowedTimelineTransitionProperties = [
|
|
9
|
+
"background-color",
|
|
10
|
+
"border-color",
|
|
11
|
+
"box-shadow",
|
|
12
|
+
"color",
|
|
13
|
+
"opacity",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function expectRule(css: string, selector: string): string {
|
|
17
|
+
const selectorStart = css.indexOf(`${selector} {`);
|
|
18
|
+
expect(selectorStart).toBeGreaterThanOrEqual(0);
|
|
19
|
+
|
|
20
|
+
const bodyStart = css.indexOf("{", selectorStart);
|
|
21
|
+
const bodyEnd = css.indexOf("}", bodyStart);
|
|
22
|
+
expect(bodyStart).toBeGreaterThanOrEqual(0);
|
|
23
|
+
expect(bodyEnd).toBeGreaterThan(bodyStart);
|
|
24
|
+
|
|
25
|
+
return css.slice(bodyStart + 1, bodyEnd).trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function expectDeclaration(ruleBody: string, property: string): string {
|
|
29
|
+
const declarationMatch = new RegExp(`${property}:\\s*([^;]+);`).exec(ruleBody);
|
|
30
|
+
expect(declarationMatch?.[1]).toBeDefined();
|
|
31
|
+
return declarationMatch?.[1].trim() ?? "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function transitionProperties(transitionDeclaration: string): string[] {
|
|
35
|
+
const items: string[] = [];
|
|
36
|
+
let depth = 0;
|
|
37
|
+
let item = "";
|
|
38
|
+
|
|
39
|
+
for (const char of transitionDeclaration) {
|
|
40
|
+
if (char === "(") depth += 1;
|
|
41
|
+
if (char === ")") depth -= 1;
|
|
42
|
+
if (char === "," && depth === 0) {
|
|
43
|
+
items.push(item.trim());
|
|
44
|
+
item = "";
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
item += char;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (item.trim().length > 0) items.push(item.trim());
|
|
51
|
+
|
|
52
|
+
return items.map((transition) => transition.split(/\s+/)[0]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("timeline motion styles", () => {
|
|
56
|
+
it("keeps clip motion reduced-motion gated and layout safe", () => {
|
|
57
|
+
const mediaStart = studioCss.indexOf("@media (prefers-reduced-motion: no-preference)");
|
|
58
|
+
expect(mediaStart).toBeGreaterThanOrEqual(0);
|
|
59
|
+
|
|
60
|
+
const beforeMotionMedia = studioCss.slice(0, mediaStart);
|
|
61
|
+
const baseTimelineClipRule = expectRule(beforeMotionMedia, ".timeline-clip");
|
|
62
|
+
expect(baseTimelineClipRule).not.toContain("transition");
|
|
63
|
+
|
|
64
|
+
const motionMediaCss = studioCss.slice(mediaStart);
|
|
65
|
+
const timelineClipMotionRule = expectRule(motionMediaCss, ".timeline-clip");
|
|
66
|
+
const clipTransition = expectDeclaration(timelineClipMotionRule, "transition");
|
|
67
|
+
|
|
68
|
+
expect(transitionProperties(clipTransition)).toEqual(allowedTimelineTransitionProperties);
|
|
69
|
+
expect(clipTransition).not.toMatch(/\b(?:all|left|width|top|bottom|transform)\b/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("layers the active mint bloom through opacity instead of a gradient background swap", () => {
|
|
73
|
+
const baseTimelineClipRule = expectRule(studioCss, ".timeline-clip");
|
|
74
|
+
const activeTimelineClipRule = expectRule(studioCss, ".timeline-clip[data-active]");
|
|
75
|
+
const bloomOverlayRule = expectRule(studioCss, ".timeline-clip::before");
|
|
76
|
+
const activeBloomOverlayRule = expectRule(studioCss, ".timeline-clip[data-active]::before");
|
|
77
|
+
|
|
78
|
+
expect(baseTimelineClipRule).toContain("background-color: rgba(255, 255, 255, 0.055)");
|
|
79
|
+
expect(activeTimelineClipRule).not.toContain("background: linear-gradient");
|
|
80
|
+
expect(activeTimelineClipRule).toContain("border-color: rgba(60, 230, 172, 0.55)");
|
|
81
|
+
expect(activeTimelineClipRule).not.toContain("box-shadow");
|
|
82
|
+
expect(bloomOverlayRule).toContain("background: rgba(60, 230, 172, 0.2)");
|
|
83
|
+
expect(bloomOverlayRule).not.toContain("linear-gradient");
|
|
84
|
+
expect(bloomOverlayRule).toContain("opacity: 0");
|
|
85
|
+
expect(activeBloomOverlayRule).toContain("opacity: 1");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("targets trim handle bars without changing drag geometry", () => {
|
|
89
|
+
const handleClassMatches = timelineClipSource.match(/className="timeline-clip__handle-bar"/g);
|
|
90
|
+
|
|
91
|
+
expect(handleClassMatches).toHaveLength(2);
|
|
92
|
+
expect(timelineClipSource).toContain('transform: isDragging ? "translateY(-1px)" : undefined');
|
|
93
|
+
expect(timelineClipSource).not.toContain("scale(");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("keeps the playhead polish static, without transition-driven positioning", () => {
|
|
97
|
+
expect(playheadSource).toContain("boxShadow");
|
|
98
|
+
expect(playheadSource).toContain("rotate(45deg)");
|
|
99
|
+
expect(playheadSource).not.toContain("transition");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -4,14 +4,31 @@ import {
|
|
|
4
4
|
getRenderedTimelineElement,
|
|
5
5
|
getTimelineTrackStyle,
|
|
6
6
|
} from "./timelineTheme";
|
|
7
|
+
import { getTrackStyle } from "./timelineIcons";
|
|
7
8
|
|
|
8
9
|
describe("getTimelineTrackStyle", () => {
|
|
9
|
-
it("
|
|
10
|
-
|
|
10
|
+
it("uses one neutral clip style for every timeline tag", () => {
|
|
11
|
+
const expectedStyle = {
|
|
12
|
+
clip: "rgba(255,255,255,0.055)",
|
|
13
|
+
clipActive: "rgba(60,230,172,0.16)",
|
|
14
|
+
accent: "#3CE6AC",
|
|
15
|
+
label: "rgba(255,255,255,0.5)",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
expect(getTimelineTrackStyle("video")).toEqual(expectedStyle);
|
|
19
|
+
expect(getTimelineTrackStyle("audio")).toEqual(expectedStyle);
|
|
20
|
+
expect(getTimelineTrackStyle("custom-tag")).toEqual(expectedStyle);
|
|
21
|
+
expect(getTimelineTrackStyle("video")).toEqual(getTimelineTrackStyle("audio"));
|
|
22
|
+
expect(getTimelineTrackStyle("video")).toEqual(getTimelineTrackStyle("custom-tag"));
|
|
11
23
|
});
|
|
24
|
+
});
|
|
12
25
|
|
|
13
|
-
|
|
14
|
-
|
|
26
|
+
describe("getTrackStyle", () => {
|
|
27
|
+
it("returns the timeline style only and preserves the empty tag fallback", () => {
|
|
28
|
+
const style = getTrackStyle("");
|
|
29
|
+
expect(style).toEqual(getTimelineTrackStyle("div"));
|
|
30
|
+
expect(Object.keys(style)).not.toContain("icon");
|
|
31
|
+
expect(Object.keys(style)).not.toContain("iconBackground");
|
|
15
32
|
});
|
|
16
33
|
});
|
|
17
34
|
|
|
@@ -4,7 +4,7 @@ export interface TimelineTrackStyle {
|
|
|
4
4
|
clip: string;
|
|
5
5
|
accent: string;
|
|
6
6
|
label: string;
|
|
7
|
-
|
|
7
|
+
clipActive?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export interface TimelineTheme {
|
|
@@ -36,25 +36,25 @@ export interface TimelineTheme {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const TRACK_STYLE: TimelineTrackStyle = {
|
|
39
|
-
clip: "
|
|
39
|
+
clip: "rgba(255,255,255,0.055)",
|
|
40
|
+
clipActive: "rgba(60,230,172,0.16)",
|
|
40
41
|
accent: "#3CE6AC",
|
|
41
|
-
label: "
|
|
42
|
-
iconBackground: "rgba(255,255,255,0.06)",
|
|
42
|
+
label: "rgba(255,255,255,0.5)",
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
export const defaultTimelineTheme: TimelineTheme = {
|
|
46
46
|
shellBackground: "#0A0A0B",
|
|
47
47
|
shellBorder: "rgba(255,255,255,0.05)",
|
|
48
|
-
rulerBorder: "rgba(255,255,255,0.
|
|
49
|
-
rowBackground: "#
|
|
50
|
-
rowBorder: "rgba(255,255,255,0.
|
|
51
|
-
gutterBackground: "#
|
|
52
|
-
gutterBorder: "rgba(255,255,255,0.
|
|
53
|
-
textPrimary: "
|
|
54
|
-
textSecondary: "
|
|
55
|
-
tickText: "rgba(
|
|
56
|
-
tickMajor: "rgba(255,255,255,0.
|
|
57
|
-
tickMinor: "rgba(255,255,255,0.
|
|
48
|
+
rulerBorder: "rgba(255,255,255,0.16)",
|
|
49
|
+
rowBackground: "#0B0C0F",
|
|
50
|
+
rowBorder: "rgba(255,255,255,0.06)",
|
|
51
|
+
gutterBackground: "#0E0F12",
|
|
52
|
+
gutterBorder: "rgba(255,255,255,0.10)",
|
|
53
|
+
textPrimary: "rgba(255,255,255,0.92)",
|
|
54
|
+
textSecondary: "rgba(255,255,255,0.62)",
|
|
55
|
+
tickText: "rgba(255,255,255,0.34)",
|
|
56
|
+
tickMajor: "rgba(255,255,255,0.10)",
|
|
57
|
+
tickMinor: "rgba(255,255,255,0.06)",
|
|
58
58
|
clipBackground: "#141922",
|
|
59
59
|
clipBackgroundActive: "#181e28",
|
|
60
60
|
clipBorder: "rgba(255,255,255,0.10)",
|
|
@@ -67,7 +67,7 @@ export const defaultTimelineTheme: TimelineTheme = {
|
|
|
67
67
|
handleColor: "rgba(255,255,255,0.2)",
|
|
68
68
|
panelResizeSeam: "rgba(255,255,255,0.12)",
|
|
69
69
|
panelResizeActive: "rgba(255,255,255,0.24)",
|
|
70
|
-
clipRadius: "
|
|
70
|
+
clipRadius: "8px",
|
|
71
71
|
};
|
|
72
72
|
|
|
73
73
|
export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { updateTimelineActiveClipClasses } from "./useTimelineActiveClips";
|
|
5
|
+
|
|
6
|
+
function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
|
|
7
|
+
const clip = document.createElement("div");
|
|
8
|
+
clip.dataset.clip = "true";
|
|
9
|
+
clip.dataset.elId = id;
|
|
10
|
+
clip.dataset.clipStart = start;
|
|
11
|
+
clip.dataset.clipEnd = end;
|
|
12
|
+
container.append(clip);
|
|
13
|
+
return clip;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("updateTimelineActiveClipClasses", () => {
|
|
17
|
+
it("toggles data-active only for clips containing the current time", () => {
|
|
18
|
+
const container = document.createElement("div");
|
|
19
|
+
const intro = appendClip(container, "intro", "0", "2");
|
|
20
|
+
const hero = appendClip(container, "hero", "2", "5");
|
|
21
|
+
const outro = appendClip(container, "outro", "5", "8");
|
|
22
|
+
const previous = new Set<string>();
|
|
23
|
+
|
|
24
|
+
updateTimelineActiveClipClasses(container, previous, 2.25);
|
|
25
|
+
|
|
26
|
+
expect(intro.hasAttribute("data-active")).toBe(false);
|
|
27
|
+
expect(hero.hasAttribute("data-active")).toBe(true);
|
|
28
|
+
expect(outro.hasAttribute("data-active")).toBe(false);
|
|
29
|
+
expect(previous).toEqual(new Set(["hero"]));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("never marks hidden clips active inside their time window", () => {
|
|
33
|
+
const container = document.createElement("div");
|
|
34
|
+
const hidden = appendClip(container, "hidden", "0", "5");
|
|
35
|
+
const visible = appendClip(container, "visible", "0", "5");
|
|
36
|
+
hidden.dataset.clipHidden = "true";
|
|
37
|
+
const previous = new Set<string>();
|
|
38
|
+
|
|
39
|
+
updateTimelineActiveClipClasses(container, previous, 2);
|
|
40
|
+
|
|
41
|
+
expect(hidden.hasAttribute("data-active")).toBe(false);
|
|
42
|
+
expect(visible.hasAttribute("data-active")).toBe(true);
|
|
43
|
+
expect(previous).toEqual(new Set(["visible"]));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("diffs against the previous active set", () => {
|
|
47
|
+
const container = document.createElement("div");
|
|
48
|
+
const intro = appendClip(container, "intro", "0", "2");
|
|
49
|
+
const hero = appendClip(container, "hero", "2", "5");
|
|
50
|
+
const previous = new Set(["intro"]);
|
|
51
|
+
intro.toggleAttribute("data-active", true);
|
|
52
|
+
|
|
53
|
+
updateTimelineActiveClipClasses(container, previous, 2);
|
|
54
|
+
|
|
55
|
+
expect(intro.hasAttribute("data-active")).toBe(true);
|
|
56
|
+
expect(hero.hasAttribute("data-active")).toBe(true);
|
|
57
|
+
expect(previous).toEqual(new Set(["intro", "hero"]));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keeps a clip active through its inclusive end boundary", () => {
|
|
61
|
+
const container = document.createElement("div");
|
|
62
|
+
const intro = appendClip(container, "intro", "0", "2");
|
|
63
|
+
const previous = new Set<string>();
|
|
64
|
+
|
|
65
|
+
updateTimelineActiveClipClasses(container, previous, 0);
|
|
66
|
+
|
|
67
|
+
expect(intro.hasAttribute("data-active")).toBe(true);
|
|
68
|
+
expect(previous).toEqual(new Set(["intro"]));
|
|
69
|
+
|
|
70
|
+
updateTimelineActiveClipClasses(container, previous, 2);
|
|
71
|
+
|
|
72
|
+
expect(intro.hasAttribute("data-active")).toBe(true);
|
|
73
|
+
expect(previous).toEqual(new Set(["intro"]));
|
|
74
|
+
|
|
75
|
+
updateTimelineActiveClipClasses(container, previous, 2.001);
|
|
76
|
+
|
|
77
|
+
expect(intro.hasAttribute("data-active")).toBe(false);
|
|
78
|
+
expect(previous).toEqual(new Set());
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("ignores clips with invalid timing data", () => {
|
|
82
|
+
const container = document.createElement("div");
|
|
83
|
+
const missingId = appendClip(container, "", "0", "2");
|
|
84
|
+
const missingTiming = appendClip(container, "bad", "", "2");
|
|
85
|
+
const previous = new Set<string>();
|
|
86
|
+
|
|
87
|
+
updateTimelineActiveClipClasses(container, previous, 1);
|
|
88
|
+
|
|
89
|
+
expect(missingId.hasAttribute("data-active")).toBe(false);
|
|
90
|
+
expect(missingTiming.hasAttribute("data-active")).toBe(false);
|
|
91
|
+
expect(previous).toEqual(new Set());
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
2
|
+
import { liveTime } from "../store/playerStore";
|
|
3
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
+
|
|
5
|
+
interface ActiveClipRecord {
|
|
6
|
+
id: string;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
hidden: boolean;
|
|
10
|
+
element: HTMLElement;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface UseTimelineActiveClipsInput {
|
|
14
|
+
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
15
|
+
currentTime: number;
|
|
16
|
+
clipStateVersion: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readFiniteNumber(value: string | undefined): number | null {
|
|
20
|
+
if (value === undefined || value.trim() === "") return null;
|
|
21
|
+
const parsed = Number(value);
|
|
22
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readClipRecord(element: Element): ActiveClipRecord | null {
|
|
26
|
+
if (!(element instanceof HTMLElement)) return null;
|
|
27
|
+
const id = element.dataset.elId;
|
|
28
|
+
const start = readFiniteNumber(element.dataset.clipStart);
|
|
29
|
+
const end = readFiniteNumber(element.dataset.clipEnd);
|
|
30
|
+
const hidden = element.dataset.clipHidden === "true";
|
|
31
|
+
if (!id || start === null || end === null) return null;
|
|
32
|
+
return { id, start, end, hidden, element };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectTimelineClipRecords(container: HTMLElement): ActiveClipRecord[] {
|
|
36
|
+
const records: ActiveClipRecord[] = [];
|
|
37
|
+
for (const element of container.querySelectorAll('[data-clip="true"]')) {
|
|
38
|
+
const record = readClipRecord(element);
|
|
39
|
+
if (record) records.push(record);
|
|
40
|
+
}
|
|
41
|
+
return records;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function indexClipRecordsById(records: ActiveClipRecord[]): Map<string, ActiveClipRecord> {
|
|
45
|
+
const recordsById = new Map<string, ActiveClipRecord>();
|
|
46
|
+
for (const record of records) recordsById.set(record.id, record);
|
|
47
|
+
return recordsById;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getActiveClipIds(records: ActiveClipRecord[], time: number): Set<string> {
|
|
51
|
+
const next = new Set<string>();
|
|
52
|
+
if (!Number.isFinite(time)) return next;
|
|
53
|
+
for (const record of records) {
|
|
54
|
+
if (record.hidden) continue;
|
|
55
|
+
if (time >= record.start && time <= record.end) next.add(record.id);
|
|
56
|
+
}
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setsMatch(left: Set<string>, right: Set<string>): boolean {
|
|
61
|
+
if (left.size !== right.size) return false;
|
|
62
|
+
for (const value of left) {
|
|
63
|
+
if (!right.has(value)) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function applyActiveClipDiff(records: ActiveClipRecord[], previous: Set<string>, time: number) {
|
|
69
|
+
const next = getActiveClipIds(records, time);
|
|
70
|
+
const changed = !setsMatch(previous, next);
|
|
71
|
+
for (const record of records) {
|
|
72
|
+
const wasActive = previous.has(record.id);
|
|
73
|
+
const isActive = next.has(record.id);
|
|
74
|
+
if (wasActive === isActive) continue;
|
|
75
|
+
record.element.toggleAttribute("data-active", isActive);
|
|
76
|
+
}
|
|
77
|
+
previous.clear();
|
|
78
|
+
for (const id of next) previous.add(id);
|
|
79
|
+
return changed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function updateTimelineActiveClipClasses(
|
|
83
|
+
container: HTMLElement,
|
|
84
|
+
previous: Set<string>,
|
|
85
|
+
time: number,
|
|
86
|
+
) {
|
|
87
|
+
applyActiveClipDiff(collectTimelineClipRecords(container), previous, time);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function useTimelineActiveClips({
|
|
91
|
+
scrollRef,
|
|
92
|
+
currentTime,
|
|
93
|
+
clipStateVersion,
|
|
94
|
+
}: UseTimelineActiveClipsInput) {
|
|
95
|
+
const recordsRef = useRef<ActiveClipRecord[]>([]);
|
|
96
|
+
const recordsByIdRef = useRef(new Map<string, ActiveClipRecord>());
|
|
97
|
+
const previousActiveIdsRef = useRef(new Set<string>());
|
|
98
|
+
|
|
99
|
+
const refreshRecords = useCallback(
|
|
100
|
+
(time: number) => {
|
|
101
|
+
const scroll = scrollRef.current;
|
|
102
|
+
if (!scroll) {
|
|
103
|
+
recordsRef.current = [];
|
|
104
|
+
recordsByIdRef.current.clear();
|
|
105
|
+
previousActiveIdsRef.current.clear();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
recordsRef.current = collectTimelineClipRecords(scroll);
|
|
109
|
+
recordsByIdRef.current = indexClipRecordsById(recordsRef.current);
|
|
110
|
+
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
|
|
111
|
+
},
|
|
112
|
+
[scrollRef],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
useLayoutEffect(() => {
|
|
116
|
+
refreshRecords(currentTime);
|
|
117
|
+
}, [currentTime, clipStateVersion, refreshRecords]);
|
|
118
|
+
|
|
119
|
+
useMountEffect(() => {
|
|
120
|
+
const unsub = liveTime.subscribe((time) => {
|
|
121
|
+
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
|
|
122
|
+
});
|
|
123
|
+
return unsub;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -94,6 +94,12 @@ export function useTimelinePlayhead({
|
|
|
94
94
|
syncPlayheadPosition(currentTime);
|
|
95
95
|
}, [currentTime, pps, syncPlayheadPosition]);
|
|
96
96
|
|
|
97
|
+
useLayoutEffect(() => {
|
|
98
|
+
const scroll = scrollRef.current;
|
|
99
|
+
if (!scroll || zoomMode !== "fit") return;
|
|
100
|
+
scroll.scrollLeft = 0;
|
|
101
|
+
}, [zoomMode, pps, scrollRef]);
|
|
102
|
+
|
|
97
103
|
useEffect(() => {
|
|
98
104
|
const scroll = scrollRef.current;
|
|
99
105
|
if (!scroll) {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildExpandedElements,
|
|
4
|
+
resolveTimelineExpansionRawId,
|
|
5
|
+
} from "./useExpandedTimelineElements";
|
|
3
6
|
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
|
|
4
7
|
import type { TimelineElement } from "../store/playerStore";
|
|
5
8
|
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
@@ -19,8 +22,14 @@ const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
|
|
|
19
22
|
...over,
|
|
20
23
|
});
|
|
21
24
|
|
|
22
|
-
const el = (over: Partial<TimelineElement>): TimelineElement =>
|
|
23
|
-
|
|
25
|
+
const el = (over: Partial<TimelineElement>): TimelineElement => ({
|
|
26
|
+
id: "x",
|
|
27
|
+
start: 0,
|
|
28
|
+
duration: 1,
|
|
29
|
+
track: 0,
|
|
30
|
+
tag: "div",
|
|
31
|
+
...over,
|
|
32
|
+
});
|
|
24
33
|
|
|
25
34
|
describe("buildExpandedElements", () => {
|
|
26
35
|
it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
|
|
@@ -42,6 +51,7 @@ describe("buildExpandedElements", () => {
|
|
|
42
51
|
expect(child.sourceFile).toBe("stats.html");
|
|
43
52
|
});
|
|
44
53
|
|
|
54
|
+
// fallow-ignore-next-line code-duplication
|
|
45
55
|
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
|
|
46
56
|
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
|
|
47
57
|
// Edits must rebase onto B (12 / b.html), not A (10 / a.html).
|
|
@@ -65,6 +75,7 @@ describe("buildExpandedElements", () => {
|
|
|
65
75
|
expect(child.sourceFile).toBe("b.html"); // B's file, not a.html
|
|
66
76
|
});
|
|
67
77
|
|
|
78
|
+
// fallow-ignore-next-line code-duplication
|
|
68
79
|
it("rebases a 3-level child onto its deepest host, not intermediate or top", () => {
|
|
69
80
|
// A@10 (a.html) → B@12 (b.html) → C@13 (c.html); leaf D lives in c.html.
|
|
70
81
|
// Edits must rebase onto C (13 / c.html), not B (12 / b.html) or A (10 / a.html).
|
|
@@ -166,3 +177,99 @@ describe("buildExpandedElements", () => {
|
|
|
166
177
|
expect(out.some((e) => e.domId === "scene-host")).toBe(false);
|
|
167
178
|
});
|
|
168
179
|
});
|
|
180
|
+
|
|
181
|
+
describe("resolveTimelineExpansionRawId", () => {
|
|
182
|
+
it("returns null when paused inside a childless top-level clip", () => {
|
|
183
|
+
const manifest = [clip({ id: "title", start: 0, duration: 4 })];
|
|
184
|
+
|
|
185
|
+
expect(
|
|
186
|
+
resolveTimelineExpansionRawId({
|
|
187
|
+
selectedElementId: null,
|
|
188
|
+
isPlaying: false,
|
|
189
|
+
currentTime: 2,
|
|
190
|
+
manifest,
|
|
191
|
+
parentMap: new Map(),
|
|
192
|
+
}),
|
|
193
|
+
).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("auto-expands an active composition with children when paused and nothing is selected", () => {
|
|
197
|
+
const manifest = [
|
|
198
|
+
clip({ id: "scene", start: 1, duration: 5 }),
|
|
199
|
+
clip({ id: "headline", start: 1.5, duration: 2 }),
|
|
200
|
+
];
|
|
201
|
+
const parentMap = new Map([["headline", "scene"]]);
|
|
202
|
+
|
|
203
|
+
expect(
|
|
204
|
+
resolveTimelineExpansionRawId({
|
|
205
|
+
selectedElementId: null,
|
|
206
|
+
isPlaying: false,
|
|
207
|
+
currentTime: 2,
|
|
208
|
+
manifest,
|
|
209
|
+
parentMap,
|
|
210
|
+
}),
|
|
211
|
+
).toBe("scene");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("auto-expands the innermost active nested composition when paused", () => {
|
|
215
|
+
const manifest = [
|
|
216
|
+
clip({ id: "outer", start: 0, duration: 10 }),
|
|
217
|
+
clip({ id: "inner", start: 2, duration: 5 }),
|
|
218
|
+
clip({ id: "leaf", start: 3, duration: 1 }),
|
|
219
|
+
];
|
|
220
|
+
const parentMap = new Map([
|
|
221
|
+
["inner", "outer"],
|
|
222
|
+
["leaf", "inner"],
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
expect(
|
|
226
|
+
resolveTimelineExpansionRawId({
|
|
227
|
+
selectedElementId: null,
|
|
228
|
+
isPlaying: false,
|
|
229
|
+
currentTime: 3.5,
|
|
230
|
+
manifest,
|
|
231
|
+
parentMap,
|
|
232
|
+
}),
|
|
233
|
+
).toBe("inner");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("does not auto-expand an active composition while playing", () => {
|
|
237
|
+
const manifest = [
|
|
238
|
+
clip({ id: "scene", start: 0, duration: 5 }),
|
|
239
|
+
clip({ id: "headline", start: 1, duration: 2 }),
|
|
240
|
+
];
|
|
241
|
+
const parentMap = new Map([["headline", "scene"]]);
|
|
242
|
+
|
|
243
|
+
expect(
|
|
244
|
+
resolveTimelineExpansionRawId({
|
|
245
|
+
selectedElementId: null,
|
|
246
|
+
isPlaying: true,
|
|
247
|
+
currentTime: 2,
|
|
248
|
+
manifest,
|
|
249
|
+
parentMap,
|
|
250
|
+
}),
|
|
251
|
+
).toBeNull();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("keeps selected elements ahead of paused active composition auto-expansion", () => {
|
|
255
|
+
const manifest = [
|
|
256
|
+
clip({ id: "scene", start: 0, duration: 6 }),
|
|
257
|
+
clip({ id: "headline", start: 1, duration: 2 }),
|
|
258
|
+
clip({ id: "caption", start: 4, duration: 1 }),
|
|
259
|
+
];
|
|
260
|
+
const parentMap = new Map([
|
|
261
|
+
["headline", "scene"],
|
|
262
|
+
["caption", "scene"],
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
expect(
|
|
266
|
+
resolveTimelineExpansionRawId({
|
|
267
|
+
selectedElementId: "caption",
|
|
268
|
+
isPlaying: false,
|
|
269
|
+
currentTime: 1.5,
|
|
270
|
+
manifest,
|
|
271
|
+
parentMap,
|
|
272
|
+
}),
|
|
273
|
+
).toBe("caption");
|
|
274
|
+
});
|
|
275
|
+
});
|