Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAddAssetAtPlayhead } from "./hooks/useAddAssetAtPlayhead";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
Expand Down Expand Up @@ -195,14 +196,7 @@ export function StudioApp() {
},
[timelineEditing.handleTimelineGroupMove],
);
const handleAddAssetAtPlayhead = useCallback(
(assetPath: string) =>
timelineEditing.handleTimelineAssetDrop(assetPath, {
start: usePlayerStore.getState().currentTime,
track: 0,
}),
[timelineEditing],
);
const handleAddAssetAtPlayhead = useAddAssetAtPlayhead(timelineEditing.handleTimelineAssetDrop);
const {
activeBlockParams,
setActiveBlockParams,
Expand Down Expand Up @@ -533,6 +527,7 @@ export function StudioApp() {
onToggleRecording={recordingToggle}
sdkSession={sdkHandle.session}
publishSdkSession={sdkHandle.publish}
forceReloadSdkSession={sdkHandle.forceReload}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={editHistory.recordEdit}
Expand Down
28 changes: 27 additions & 1 deletion packages/studio/src/components/DesignPanelPromoteProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import { useCallback, type ReactNode } from "react";
import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { useSdkSession } from "../hooks/useSdkSession";
import { useVariablesPersist, type UseVariablesPersistParams } from "../hooks/useVariablesPersist";
Expand All @@ -24,23 +25,48 @@ export function DesignPanelPromoteProvider({
projectId,
activeCompPath,
showToast,
forceReloadSharedSdkSession,
children,
...persistDeps
}: PersistDeps & {
selection: DomEditSelection | null;
projectId: string | null;
activeCompPath: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
/**
* Forces the app's SHARED SDK session (Variables tab, Slideshow, etc.) to
* re-open from disk. This provider opens its OWN session, separate from
* that shared one, so a persist through it never fires the shared session's
* own "change" event. When the target happens to be the same file the
* shared session already has open, that session is left holding stale
* in-memory content after a successful persist — worse, the self-write-echo
* registry that would normally reload it on the next file-change
* notification is keyed by file path only (not by session instance), so it
* mistakes this provider's write for its own echo and stays stale
* indefinitely. Called unconditionally (not gated on targetPath matching
* activeCompPath): re-opening a file that didn't change is a harmless
* no-op re-parse, cheaper than the bug class a subtly-wrong path guard
* could reintroduce.
*/
forceReloadSharedSdkSession?: () => void;
children: ReactNode;
}) {
const targetPath = selection?.sourceFile || activeCompPath || "index.html";
const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
const persist = useVariablesPersist({
const rawPersist = useVariablesPersist({
...persistDeps,
sdkSession: handle.session,
publishSdkSession: handle.publish,
activeCompPath: targetPath,
});
const persist = useCallback(
async (label: string, mutate: (session: Composition) => void) => {
const committed = await rawPersist(label, mutate);
if (committed) forceReloadSharedSdkSession?.();
return committed;
},
[rawPersist, forceReloadSharedSdkSession],
);
const handlePersistError = useCallback(
(error: unknown) => showToast(getStudioSaveErrorMessage(error), "error"),
[showToast],
Expand Down
15 changes: 15 additions & 0 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ export interface StudioRightPanelProps {
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
sdkSession: Composition | null;
publishSdkSession: NonNullable<UseSlideshowPersistParams["publishSdkSession"]>;
/**
* Forces THIS `sdkSession` to re-open from disk. DesignPanelPromoteProvider
* opens its own separate SDK session scoped to the selected element's own
* file (needed so promoting inside a sub-composition binds a variable there,
* not on the host) — for a top-level selection that's the SAME file this
* session already has open, so a write through that other session leaves
* this one holding stale in-memory content. The self-write-echo registry
* that normally suppresses redundant reloads is keyed by file path only, not
* by session instance, so it wrongly treats the sibling session's write as
* "our own echo" and never reloads on its own — this must be called
* explicitly after such a write.
*/
forceReloadSdkSession?: () => void;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
recordEdit: (entry: {
Expand All @@ -71,6 +84,7 @@ export function StudioRightPanel({
onToggleRecording,
sdkSession,
publishSdkSession,
forceReloadSdkSession,
reloadPreview,
domEditSaveTimestampRef,
recordEdit,
Expand Down Expand Up @@ -336,6 +350,7 @@ export function StudioRightPanel({
recordEdit={recordEdit}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
forceReloadSharedSdkSession={forceReloadSdkSession}
>
<PropertyPanel
projectId={projectId}
Expand Down
44 changes: 44 additions & 0 deletions packages/studio/src/hooks/useAddAssetAtPlayhead.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../player";
import { useAddAssetAtPlayhead } from "./useAddAssetAtPlayhead";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

afterEach(() => {
document.body.innerHTML = "";
});

describe("useAddAssetAtPlayhead", () => {
it("drops the asset on track 0 at the current playhead time", () => {
usePlayerStore.getState().setCurrentTime(4.5);
const handleTimelineAssetDrop = vi.fn();
let addAsset: (assetPath: string) => unknown = () => undefined;

function Harness() {
addAsset = useAddAssetAtPlayhead(handleTimelineAssetDrop);
return null;
}

const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(React.createElement(Harness));
});

act(() => {
addAsset("assets/clip.mp4");
});

expect(handleTimelineAssetDrop).toHaveBeenCalledWith("assets/clip.mp4", {
start: 4.5,
track: 0,
});

act(() => root.unmount());
});
});
21 changes: 21 additions & 0 deletions packages/studio/src/hooks/useAddAssetAtPlayhead.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useCallback } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";

/** Drops an asset onto track 0 at the current playhead time. */
export function useAddAssetAtPlayhead(
handleTimelineAssetDrop: (
assetPath: string,
placement: Pick<TimelineElement, "start" | "track">,
durationOverride?: number,
) => unknown,
) {
return useCallback(
(assetPath: string) =>
handleTimelineAssetDrop(assetPath, {
start: usePlayerStore.getState().currentTime,
track: 0,
}),
[handleTimelineAssetDrop],
);
}
Loading