@hyperframes/studio 0.6.88 → 0.6.90
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-BKuDHMYl.js +146 -0
- package/dist/assets/index-D2NkPomd.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +33 -193
- package/src/components/StudioLeftSidebar.tsx +6 -0
- package/src/components/StudioRightPanel.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +54 -31
- package/src/components/editor/AnimationCard.tsx +15 -3
- package/src/components/editor/DomEditOverlay.test.ts +34 -1
- package/src/components/editor/FileTree.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +17 -3
- package/src/components/editor/LayersPanel.tsx +19 -4
- package/src/components/editor/PropertyPanel.tsx +82 -170
- package/src/components/editor/domEditOverlayStartGesture.ts +1 -0
- package/src/components/editor/gsapAnimatesProperty.ts +52 -0
- package/src/components/editor/manualEditsDom.ts +11 -57
- package/src/components/editor/manualOffsetDrag.test.ts +18 -1
- package/src/components/editor/manualOffsetDrag.ts +16 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +133 -0
- package/src/components/editor/propertyPanelHelpers.ts +76 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +1 -9
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useLayerDrag.ts +6 -3
- package/src/components/renders/RenderQueueItem.tsx +47 -46
- package/src/components/sidebar/CompositionsTab.tsx +15 -2
- package/src/components/sidebar/LeftSidebar.tsx +11 -0
- package/src/hooks/gsapDragCommit.ts +294 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +88 -0
- package/src/hooks/gsapRuntimeBridge.ts +49 -402
- package/src/hooks/gsapRuntimeReaders.ts +201 -0
- package/src/hooks/timelineEditingHelpers.ts +148 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +54 -12
- package/src/hooks/useBlockHandlers.ts +150 -0
- package/src/hooks/useClipboard.ts +1 -10
- package/src/hooks/useDomEditPreviewSync.ts +126 -0
- package/src/hooks/useDomEditSession.ts +11 -79
- package/src/hooks/useGestureCommit.ts +166 -0
- package/src/hooks/useGestureRecording.ts +271 -169
- package/src/hooks/useGsapScriptCommits.ts +7 -80
- package/src/hooks/useLintModal.ts +97 -25
- package/src/hooks/useTimelineEditing.ts +10 -132
- package/src/player/components/TimelineCanvas.tsx +24 -7
- package/src/player/components/useTimelinePlayhead.ts +2 -1
- package/src/player/store/playerStore.ts +12 -0
- package/src/utils/gsapSoftReload.ts +18 -1
- package/src/utils/studioUrlState.test.ts +9 -0
- package/dist/assets/index-B9_ctmee.js +0 -143
- package/dist/assets/index-CGlIm_-E.css +0 -1
|
@@ -1,35 +1,107 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
1
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
2
2
|
import type { LintFinding } from "../components/LintModal";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
interface RawFinding {
|
|
5
|
+
severity?: string;
|
|
6
|
+
message?: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
fixHint?: string;
|
|
9
|
+
elementId?: string;
|
|
10
|
+
selector?: string;
|
|
11
|
+
code?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseFinding(f: RawFinding): LintFinding & { elementId?: string; file?: string } {
|
|
15
|
+
return {
|
|
16
|
+
severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
|
|
17
|
+
message: f.message ?? "",
|
|
18
|
+
file: f.file,
|
|
19
|
+
fixHint: f.fixHint,
|
|
20
|
+
elementId: f.elementId,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useLintModal(projectId: string | null, refreshKey?: number) {
|
|
5
25
|
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
|
|
6
26
|
const [linting, setLinting] = useState(false);
|
|
27
|
+
const [backgroundFindings, setBackgroundFindings] = useState<
|
|
28
|
+
Array<LintFinding & { elementId?: string; file?: string }>
|
|
29
|
+
>([]);
|
|
30
|
+
const autoLintRanRef = useRef(false);
|
|
31
|
+
|
|
32
|
+
const runLint = useCallback(
|
|
33
|
+
async (opts?: { background?: boolean }) => {
|
|
34
|
+
if (!projectId) return;
|
|
35
|
+
if (!opts?.background) setLinting(true);
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(`/api/projects/${projectId}/lint`);
|
|
38
|
+
const data = await res.json();
|
|
39
|
+
const parsed = ((data.findings ?? []) as RawFinding[]).map(parseFinding);
|
|
40
|
+
if (opts?.background) {
|
|
41
|
+
setBackgroundFindings(parsed);
|
|
42
|
+
} else {
|
|
43
|
+
setLintModal(parsed);
|
|
44
|
+
setBackgroundFindings(parsed);
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (!opts?.background) {
|
|
48
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
49
|
+
setLintModal([{ severity: "error", message: `Failed to run lint: ${msg}` }]);
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
if (!opts?.background) setLinting(false);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
[projectId],
|
|
56
|
+
);
|
|
7
57
|
|
|
8
|
-
const handleLint = useCallback(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
(data.findings ?? []).map(
|
|
16
|
-
(f: { severity?: string; message?: string; file?: string; fixHint?: string }) => ({
|
|
17
|
-
severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
|
|
18
|
-
message: f.message ?? "",
|
|
19
|
-
file: f.file,
|
|
20
|
-
fixHint: f.fixHint,
|
|
21
|
-
}),
|
|
22
|
-
),
|
|
23
|
-
);
|
|
24
|
-
} catch (err) {
|
|
25
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
26
|
-
setLintModal([{ severity: "error", message: `Failed to run lint: ${msg}` }]);
|
|
27
|
-
} finally {
|
|
28
|
-
setLinting(false);
|
|
58
|
+
const handleLint = useCallback(() => runLint(), [runLint]);
|
|
59
|
+
|
|
60
|
+
const prevProjectIdRef = useRef(projectId);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (projectId !== prevProjectIdRef.current) {
|
|
63
|
+
autoLintRanRef.current = false;
|
|
64
|
+
prevProjectIdRef.current = projectId;
|
|
29
65
|
}
|
|
30
|
-
|
|
66
|
+
if (!projectId || autoLintRanRef.current) return;
|
|
67
|
+
autoLintRanRef.current = true;
|
|
68
|
+
void runLint({ background: true });
|
|
69
|
+
}, [projectId, runLint]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!projectId || !refreshKey) return;
|
|
73
|
+
const timer = setTimeout(() => void runLint({ background: true }), 1000);
|
|
74
|
+
return () => clearTimeout(timer);
|
|
75
|
+
}, [projectId, refreshKey, runLint]);
|
|
31
76
|
|
|
32
77
|
const closeLintModal = useCallback(() => setLintModal(null), []);
|
|
33
78
|
|
|
34
|
-
|
|
79
|
+
const groupFindings = useCallback(
|
|
80
|
+
(keyFn: (f: (typeof backgroundFindings)[0]) => string | undefined) => {
|
|
81
|
+
const map = new Map<string, { count: number; messages: string[] }>();
|
|
82
|
+
for (const f of backgroundFindings) {
|
|
83
|
+
const key = keyFn(f);
|
|
84
|
+
if (!key) continue;
|
|
85
|
+
const prev = map.get(key) ?? { count: 0, messages: [] };
|
|
86
|
+
prev.count += 1;
|
|
87
|
+
prev.messages.push(f.message);
|
|
88
|
+
map.set(key, prev);
|
|
89
|
+
}
|
|
90
|
+
return map;
|
|
91
|
+
},
|
|
92
|
+
[backgroundFindings],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
|
|
96
|
+
const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
lintModal,
|
|
100
|
+
linting,
|
|
101
|
+
handleLint,
|
|
102
|
+
closeLintModal,
|
|
103
|
+
backgroundFindings,
|
|
104
|
+
findingsByElement,
|
|
105
|
+
findingsByFile,
|
|
106
|
+
};
|
|
35
107
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import { usePlayerStore } from "../player";
|
|
4
|
-
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
|
5
|
-
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
6
4
|
import {
|
|
7
5
|
buildTimelineAssetId,
|
|
8
6
|
buildTimelineAssetInsertHtml,
|
|
@@ -19,6 +17,16 @@ import {
|
|
|
19
17
|
resolveDroppedAssetDuration,
|
|
20
18
|
} from "../utils/studioHelpers";
|
|
21
19
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
20
|
+
import {
|
|
21
|
+
buildPatchTarget,
|
|
22
|
+
patchIframeDomTiming,
|
|
23
|
+
resolveResizePlaybackStart,
|
|
24
|
+
persistTimelineEdit,
|
|
25
|
+
readFileContent,
|
|
26
|
+
applyPatchByTarget,
|
|
27
|
+
formatTimelineAttributeNumber,
|
|
28
|
+
} from "./timelineEditingHelpers";
|
|
29
|
+
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
22
30
|
|
|
23
31
|
// ── Types ──
|
|
24
32
|
|
|
@@ -44,136 +52,6 @@ interface UseTimelineEditingOptions {
|
|
|
44
52
|
isRecordingRef?: React.RefObject<boolean>;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
// ── Helpers ──
|
|
48
|
-
|
|
49
|
-
function buildPatchTarget(element: {
|
|
50
|
-
domId?: string;
|
|
51
|
-
hfId?: string;
|
|
52
|
-
selector?: string;
|
|
53
|
-
selectorIndex?: number;
|
|
54
|
-
}) {
|
|
55
|
-
if (element.domId) {
|
|
56
|
-
return {
|
|
57
|
-
id: element.domId,
|
|
58
|
-
hfId: element.hfId,
|
|
59
|
-
selector: element.selector,
|
|
60
|
-
selectorIndex: element.selectorIndex,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
if (element.hfId) {
|
|
64
|
-
return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex };
|
|
65
|
-
}
|
|
66
|
-
if (element.selector) {
|
|
67
|
-
return { selector: element.selector, selectorIndex: element.selectorIndex };
|
|
68
|
-
}
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
|
|
73
|
-
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
|
|
74
|
-
// picked up automatically on the next frame without a rebind call.
|
|
75
|
-
function patchIframeDomTiming(
|
|
76
|
-
iframe: HTMLIFrameElement | null,
|
|
77
|
-
element: TimelineElement,
|
|
78
|
-
attrs: Array<[string, string]>,
|
|
79
|
-
): void {
|
|
80
|
-
try {
|
|
81
|
-
const doc = iframe?.contentDocument;
|
|
82
|
-
if (!doc) return;
|
|
83
|
-
const el = element.domId
|
|
84
|
-
? doc.getElementById(element.domId)
|
|
85
|
-
: element.selector
|
|
86
|
-
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
|
|
87
|
-
: null;
|
|
88
|
-
if (!el) return;
|
|
89
|
-
for (const [name, value] of attrs) el.setAttribute(name, value);
|
|
90
|
-
} catch {
|
|
91
|
-
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function resolveResizePlaybackStart(
|
|
96
|
-
original: string,
|
|
97
|
-
target: PatchTarget,
|
|
98
|
-
element: TimelineElement,
|
|
99
|
-
updates: Pick<TimelineElement, "start" | "playbackStart">,
|
|
100
|
-
): { attrName: string; value: number } | null {
|
|
101
|
-
if (updates.playbackStart != null) {
|
|
102
|
-
const attrName =
|
|
103
|
-
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
|
104
|
-
return { attrName, value: updates.playbackStart };
|
|
105
|
-
}
|
|
106
|
-
const trimDelta = updates.start - element.start;
|
|
107
|
-
if (trimDelta === 0) return null;
|
|
108
|
-
const raw =
|
|
109
|
-
readAttributeByTarget(original, target, "playback-start") ??
|
|
110
|
-
readAttributeByTarget(original, target, "media-start");
|
|
111
|
-
const current = raw != null ? parseFloat(raw) : undefined;
|
|
112
|
-
if (current == null || !Number.isFinite(current)) return null;
|
|
113
|
-
const attrName =
|
|
114
|
-
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
|
115
|
-
return {
|
|
116
|
-
attrName,
|
|
117
|
-
value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
|
|
122
|
-
|
|
123
|
-
interface PersistTimelineEditInput {
|
|
124
|
-
projectId: string;
|
|
125
|
-
element: TimelineElement;
|
|
126
|
-
activeCompPath: string | null;
|
|
127
|
-
label: string;
|
|
128
|
-
buildPatches: (original: string, target: PatchTarget) => string;
|
|
129
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
130
|
-
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
131
|
-
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
132
|
-
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
|
|
136
|
-
const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
|
|
137
|
-
const originalContent = await readFileContent(input.projectId, targetPath);
|
|
138
|
-
|
|
139
|
-
const patchTarget = buildPatchTarget(input.element);
|
|
140
|
-
if (!patchTarget) {
|
|
141
|
-
throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const patchedContent = input.buildPatches(originalContent, patchTarget);
|
|
145
|
-
if (patchedContent === originalContent) {
|
|
146
|
-
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
input.pendingTimelineEditPathRef.current.add(targetPath);
|
|
150
|
-
input.domEditSaveTimestampRef.current = Date.now();
|
|
151
|
-
await saveProjectFilesWithHistory({
|
|
152
|
-
projectId: input.projectId,
|
|
153
|
-
label: input.label,
|
|
154
|
-
kind: "timeline",
|
|
155
|
-
files: { [targetPath]: patchedContent },
|
|
156
|
-
readFile: async () => originalContent,
|
|
157
|
-
writeFile: input.writeProjectFile,
|
|
158
|
-
recordEdit: input.recordEdit,
|
|
159
|
-
});
|
|
160
|
-
input.domEditSaveTimestampRef.current = Date.now();
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
|
164
|
-
const response = await fetch(
|
|
165
|
-
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
|
|
166
|
-
);
|
|
167
|
-
if (!response.ok) {
|
|
168
|
-
throw new Error(`Failed to read ${targetPath}`);
|
|
169
|
-
}
|
|
170
|
-
const data = (await response.json()) as { content?: string };
|
|
171
|
-
if (typeof data.content !== "string") {
|
|
172
|
-
throw new Error(`Missing file contents for ${targetPath}`);
|
|
173
|
-
}
|
|
174
|
-
return data.content;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
55
|
// ── Hook ──
|
|
178
56
|
|
|
179
57
|
export function useTimelineEditing({
|
|
@@ -9,11 +9,33 @@ import {
|
|
|
9
9
|
} from "./timelineEditing";
|
|
10
10
|
import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme";
|
|
11
11
|
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
|
|
12
|
-
import
|
|
12
|
+
import {
|
|
13
|
+
usePlayerStore,
|
|
14
|
+
type TimelineElement,
|
|
15
|
+
type KeyframeCacheEntry,
|
|
16
|
+
} from "../store/playerStore";
|
|
13
17
|
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
14
18
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
15
19
|
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
|
16
20
|
|
|
21
|
+
function ClipLabel({ element, color }: { element: TimelineElement; color: string }) {
|
|
22
|
+
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
className="flex items-center gap-1 truncate text-[10px] font-medium leading-none"
|
|
26
|
+
style={{ color }}
|
|
27
|
+
>
|
|
28
|
+
{element.label || element.id || element.tag}
|
|
29
|
+
{lint && lint.count > 0 && (
|
|
30
|
+
<span
|
|
31
|
+
className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-amber-400"
|
|
32
|
+
title={lint.messages.join("\n")}
|
|
33
|
+
/>
|
|
34
|
+
)}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
interface TimelineCanvasProps {
|
|
18
40
|
major: number[];
|
|
19
41
|
minor: number[];
|
|
@@ -157,12 +179,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
157
179
|
}
|
|
158
180
|
>
|
|
159
181
|
{renderClipContent?.(element, clipStyle) ?? (
|
|
160
|
-
<
|
|
161
|
-
className="truncate text-[10px] font-medium leading-none"
|
|
162
|
-
style={{ color: clipStyle.label }}
|
|
163
|
-
>
|
|
164
|
-
{element.label || element.id || element.tag}
|
|
165
|
-
</span>
|
|
182
|
+
<ClipLabel element={element} color={clipStyle.label} />
|
|
166
183
|
)}
|
|
167
184
|
</div>
|
|
168
185
|
</>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useRef, useCallback, useEffect } from "react";
|
|
2
|
-
import { liveTime, type ZoomMode } from "../store/playerStore";
|
|
2
|
+
import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
import { getPinchTimelineZoomPercent } from "./timelineZoom";
|
|
5
5
|
import {
|
|
@@ -90,6 +90,7 @@ export function useTimelinePlayhead({
|
|
|
90
90
|
if (
|
|
91
91
|
scroll &&
|
|
92
92
|
!isDragging.current &&
|
|
93
|
+
usePlayerStore.getState().isPlaying &&
|
|
93
94
|
shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth)
|
|
94
95
|
) {
|
|
95
96
|
const edgeMargin = scroll.clientWidth * 0.12;
|
|
@@ -117,6 +117,12 @@ interface PlayerState {
|
|
|
117
117
|
requestedSeekTime: number | null;
|
|
118
118
|
requestSeek: (time: number) => void;
|
|
119
119
|
clearSeekRequest: () => void;
|
|
120
|
+
|
|
121
|
+
autoKeyframeEnabled: boolean;
|
|
122
|
+
setAutoKeyframeEnabled: (enabled: boolean) => void;
|
|
123
|
+
|
|
124
|
+
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
|
|
125
|
+
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
// Lightweight pub-sub for current time during playback.
|
|
@@ -192,6 +198,12 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
192
198
|
requestSeek: (time) => set({ requestedSeekTime: time }),
|
|
193
199
|
clearSeekRequest: () => set({ requestedSeekTime: null }),
|
|
194
200
|
|
|
201
|
+
autoKeyframeEnabled: true,
|
|
202
|
+
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
|
|
203
|
+
|
|
204
|
+
lintFindingsByElement: new Map(),
|
|
205
|
+
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
|
|
206
|
+
|
|
195
207
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
|
196
208
|
setPlaybackRate: (rate) => {
|
|
197
209
|
writeStudioUiPreferences({ playbackRate: rate });
|
|
@@ -61,7 +61,24 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
|
|
61
61
|
if (timelines) {
|
|
62
62
|
for (const key of Object.keys(timelines)) {
|
|
63
63
|
try {
|
|
64
|
-
timelines[key]
|
|
64
|
+
const tl = timelines[key] as {
|
|
65
|
+
kill?: () => void;
|
|
66
|
+
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
|
67
|
+
};
|
|
68
|
+
const allTargets: Element[] = [];
|
|
69
|
+
if (tl?.getChildren) {
|
|
70
|
+
try {
|
|
71
|
+
for (const child of tl.getChildren(true)) {
|
|
72
|
+
if (typeof child.targets === "function") {
|
|
73
|
+
for (const t of child.targets()) {
|
|
74
|
+
allTargets.push(t);
|
|
75
|
+
delete (t as unknown as Record<string, unknown>)._gsap;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
tl?.kill?.();
|
|
65
82
|
} catch {}
|
|
66
83
|
delete timelines[key];
|
|
67
84
|
}
|
|
@@ -231,6 +231,15 @@ describe("studio url state", () => {
|
|
|
231
231
|
expect(window.location.hash).toContain("t=4.2");
|
|
232
232
|
expect(applyDomSelection).not.toHaveBeenCalled();
|
|
233
233
|
|
|
234
|
+
// Drive the hook's internal currentTime read. Per #1311 the hook stopped
|
|
235
|
+
// taking currentTime as a prop and now subscribes to the player store
|
|
236
|
+
// directly (usePlayerStore((s) => s.currentTime)). The harness prop is a
|
|
237
|
+
// no-op; the selection-hydration useEffect's time-stability guard
|
|
238
|
+
// (`Math.abs(currentTime - stableTimeRef.current) > 0.05`) only passes
|
|
239
|
+
// once the store's currentTime catches up to the seek target.
|
|
240
|
+
act(() => {
|
|
241
|
+
usePlayerStore.setState({ currentTime: 4.2 });
|
|
242
|
+
});
|
|
234
243
|
harness.rerender({ currentTime: 4.2 });
|
|
235
244
|
await act(async () => {
|
|
236
245
|
vi.advanceTimersByTime(250);
|