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
25 changes: 21 additions & 4 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useManifestPersistence } from "./hooks/useManifestPersistence";
import { useTimelineEditing } from "./hooks/useTimelineEditing";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
import { useCaptionDetection } from "./hooks/useCaptionDetection";
import { useRenderClipContent } from "./hooks/useRenderClipContent";
Expand Down Expand Up @@ -162,15 +163,28 @@ export function StudioApp() {

const clearDomSelectionRef = useRef<() => void>(() => {});
const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
const handleDomEditElementDeleteRef = useRef<(selection: DomEditSelection) => Promise<void>>(
const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
async () => {},
);

const domEditDeleteBridge = async (s: DomEditSelection) =>
handleDomEditElementDeleteRef.current(s);
const { handleCopy, handlePaste, handleCut } = useClipboard({
projectId,
activeCompPath,
domEditSelectionRef: domEditSelectionBridgeRef,
showToast,
writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit,
domEditSaveTimestampRef,
reloadPreview,
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
handleDomEditElementDelete: domEditDeleteBridge,
previewIframeRef,
});
const appHotkeys = useAppHotkeys({
toggleTimelineVisibility,
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
handleDomEditElementDelete: async (s: DomEditSelection) =>
handleDomEditElementDeleteRef.current(s),
handleDomEditElementDelete: domEditDeleteBridge,
domEditSelectionRef: domEditSelectionBridgeRef,
clearDomSelectionRef,
editHistory,
Expand All @@ -182,6 +196,9 @@ export function StudioApp() {
syncHistoryPreviewAfterApply: manifestPersistence.syncHistoryPreviewAfterApply,
waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
leftSidebarRef,
handleCopy,
handlePaste,
handleCut,
});

const domEditSession = useDomEditSession({
Expand Down
11 changes: 7 additions & 4 deletions packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const NLELayout = memo(function NLELayout({
togglePlay,
seek,
onIframeLoad: baseOnIframeLoad,
saveSeekPosition,
refreshPlayer,
} = useTimelinePlayer();

// Reset timeline state when the project changes
Expand All @@ -109,13 +109,16 @@ export const NLELayout = memo(function NLELayout({
usePlayerStore.getState().reset();
}

// Save seek position before refresh
// Lightweight reload: change iframe src instead of destroying the Player.
// refreshPlayer() saves the seek position and appends a cache-busting _t
// param, avoiding the full web-component teardown + crossfade that the
// key-based path uses.
const prevRefreshKeyRef = useRef(refreshKey);
useEffect(() => {
if (refreshKey === prevRefreshKeyRef.current) return;
prevRefreshKeyRef.current = refreshKey;
saveSeekPosition();
}, [refreshKey, saveSeekPosition]);
refreshPlayer();
}, [refreshKey, refreshPlayer]);

const onIframeLoad = useCallback(() => {
baseOnIframeLoad();
Expand Down
53 changes: 9 additions & 44 deletions packages/studio/src/components/nle/NLEPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
import { memo, useCallback, useEffect, useRef, type Ref } from "react";
import { Player } from "../../player";
import {
DEFAULT_PREVIEW_ZOOM,
Expand Down Expand Up @@ -53,15 +53,14 @@ export const NLEPreview = memo(function NLEPreview({
onCompositionLoadingChange,
portrait,
directUrl,
refreshKey,
suppressLoadingOverlay,
}: NLEPreviewProps) {
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
const prevRefreshKeyRef = useRef(refreshKey);
// Player key only changes for structural changes (project switch, composition
// drill-down), NOT for content refreshes. Content refreshes use the lighter
// iframe.src reload path handled by NLELayout → refreshPlayer().
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
const viewportRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [retiringKey, setRetiringKey] = useState<string | null>(null);
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
const hudRef = useRef<HTMLDivElement>(null);
Expand All @@ -80,7 +79,6 @@ export const NLEPreview = memo(function NLEPreview({
return () => {
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
};
}, []);

Expand Down Expand Up @@ -130,31 +128,13 @@ export const NLEPreview = memo(function NLEPreview({
[writeTransform],
);

if (refreshKey !== prevRefreshKeyRef.current) {
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
prevRefreshKeyRef.current = refreshKey;
setRetiringKey(oldKey);
}

const activeKey = `${baseKey}:${refreshKey ?? 0}`;

const applyInitialZoom = useCallback(() => {
const z = zoomRef.current;
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
writeTransform(z);
}
}, [writeTransform]);

const handleNewPlayerLoad = () => {
onIframeLoad();
applyInitialZoom();
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
retiringTimerRef.current = setTimeout(() => {
setRetiringKey(null);
retiringTimerRef.current = null;
}, 160);
};

useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
Expand Down Expand Up @@ -282,32 +262,17 @@ export const NLEPreview = memo(function NLEPreview({
}}
data-testid="preview-zoom-stage"
>
{retiringKey && (
<Player
key={retiringKey}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={() => {}}
portrait={portrait}
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
/>
)}
<Player
key={activeKey}
ref={iframeRef}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={
retiringKey
? handleNewPlayerLoad
: () => {
onIframeLoad();
applyInitialZoom();
}
}
onLoad={() => {
onIframeLoad();
applyInitialZoom();
}}
onCompositionLoadingChange={onCompositionLoadingChange}
portrait={portrait}
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
suppressLoadingOverlay={suppressLoadingOverlay}
/>
</div>
Expand Down
54 changes: 54 additions & 0 deletions packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ interface UseAppHotkeysParams {
syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise<void>;
waitForPendingDomEditSaves: () => Promise<void>;
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
handleCopy: () => boolean;
handlePaste: () => Promise<void>;
handleCut: () => Promise<boolean>;
}

// ── Hook ──
Expand All @@ -64,6 +67,9 @@ export function useAppHotkeys({
syncHistoryPreviewAfterApply,
waitForPendingDomEditSaves,
leftSidebarRef,
handleCopy,
handlePaste,
handleCut,
}: UseAppHotkeysParams) {
const previewHotkeyWindowRef = useRef<Window | null>(null);
const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
Expand Down Expand Up @@ -161,6 +167,12 @@ export function useAppHotkeys({
handleUndoRef.current = handleUndo;
const handleRedoRef = useRef(handleRedo);
handleRedoRef.current = handleRedo;
const handleCopyRef = useRef(handleCopy);
handleCopyRef.current = handleCopy;
const handlePasteRef = useRef(handlePaste);
handlePasteRef.current = handlePaste;
const handleCutRef = useRef(handleCut);
handleCutRef.current = handleCut;

// ── Consolidated keydown handler ──

Expand Down Expand Up @@ -197,6 +209,48 @@ export function useAppHotkeys({
leftSidebarRef.current?.selectTab("assets");
return;
}

// Cmd/Ctrl+C — copy (only preventDefault if we actually have something to copy)
const copyPasteKey = event.key.toLowerCase();
if (
copyPasteKey === "c" &&
!event.shiftKey &&
!event.altKey &&
!isEditableTarget(event.target)
) {
if (handleCopyRef.current()) {
event.preventDefault();
}
return;
}

// Cmd/Ctrl+V — paste
if (
copyPasteKey === "v" &&
!event.shiftKey &&
!event.altKey &&
!isEditableTarget(event.target)
) {
event.preventDefault();
void handlePasteRef.current();
return;
}

// Cmd/Ctrl+X — cut (only preventDefault if there's a selected element to cut)
if (
copyPasteKey === "x" &&
!event.shiftKey &&
!event.altKey &&
!isEditableTarget(event.target)
) {
const hasSelection =
!!usePlayerStore.getState().selectedElementId || !!domEditSelectionRef.current;
if (hasSelection) {
event.preventDefault();
void handleCutRef.current();
}
return;
}
}

// Delete / Backspace — remove selected element (timeline clip or preview selection)
Expand Down
Loading
Loading