Skip to content
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
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
229 changes: 229 additions & 0 deletions packages/studio/src/hooks/useClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
import { type ClipboardPayload, deduplicateIds, insertAsSibling } from "../utils/clipboardPayload";
import { collectHtmlIds } from "../utils/studioHelpers";
import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";

interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}

interface UseClipboardOptions {
projectId: string | null;
activeCompPath: string | null;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
reloadPreview: () => void;
handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
}

async function readFileContent(projectId: string, targetPath: string): Promise<string> {
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
);

Check warning

Code scanning / CodeQL

Client-side request forgery Medium

The
URL
of this request depends on a
user-provided value
.
Comment on lines +34 to +36
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
const data = (await response.json()) as { content?: string };
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${targetPath}`);
return data.content;
}

function getElementOuterHtml(
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
selection: DomEditSelection,
): string | null {
let doc: Document | null = null;
try {
doc = iframeRef.current?.contentDocument ?? null;
} catch {
return null;
}
if (!doc) return null;

let el: Element | null = null;
if (selection.id) {
el = doc.getElementById(selection.id);
}
if (!el && selection.selector) {
const matches = doc.querySelectorAll(selection.selector);
el = matches[selection.selectorIndex ?? 0] ?? null;
}
return el && "outerHTML" in el ? (el as Element).outerHTML : null;
}

export function useClipboard({
projectId,
activeCompPath,
domEditSelectionRef,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
handleTimelineElementDelete,
handleDomEditElementDelete,
previewIframeRef,
}: UseClipboardOptions) {
const clipboardRef = useRef<ClipboardPayload | null>(null);
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;

const handleCopy = useCallback((): boolean => {
const { selectedElementId, elements } = usePlayerStore.getState();

// Timeline clip copy
if (selectedElementId) {
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
if (!element) return false;
const targetPath = element.sourceFile || activeCompPath || "index.html";

let html: string | null = null;
try {
const doc = previewIframeRef.current?.contentDocument;
if (doc) {
let el: Element | null = null;
if (element.domId) el = doc.getElementById(element.domId);
if (!el && element.selector) {
const matches = doc.querySelectorAll(element.selector);
el = matches[element.selectorIndex ?? 0] ?? null;
}
if (el && "outerHTML" in el) html = (el as Element).outerHTML;
}
} catch {
// cross-origin frame
}

if (!html) {
showToast("Unable to copy this element.", "info");
return false;
}

const payload: ClipboardPayload = { kind: "timeline-clip", html, sourceFile: targetPath };
clipboardRef.current = payload;
showToast("Copied clip", "info");
return true;
}

// DOM element copy
const domSelection = domEditSelectionRef.current;
if (domSelection) {
const html = getElementOuterHtml(previewIframeRef, domSelection);
if (!html) {
showToast("Unable to copy this element.", "info");
return false;
}
const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
const payload: ClipboardPayload = {
kind: "dom-element",
html,
sourceFile: targetPath,
originSelector: domSelection.selector,
originSelectorIndex: domSelection.selectorIndex,
};
clipboardRef.current = payload;
showToast("Copied element", "info");
return true;
}

return false;
}, [activeCompPath, domEditSelectionRef, previewIframeRef, showToast]);

const handlePaste = useCallback(async () => {
const payload = clipboardRef.current;
if (!payload) {
showToast("Nothing to paste.", "info");
return;
}
const pid = projectIdRef.current;
if (!pid) return;

const targetPath = activeCompPath || "index.html";
try {
const originalContent = await readFileContent(pid, targetPath);
const existingIds = collectHtmlIds(originalContent);
const deduped = deduplicateIds(payload.html, existingIds);

let patchedContent: string;
if (payload.kind === "timeline-clip") {
// Only rewrite data-start on the outermost opening tag. The non-global
// regex matches the first occurrence, which is always in the root tag
// since outerHTML starts with it. Nested clips keep their own timing.
const { currentTime } = usePlayerStore.getState();
const rootTagEnd = deduped.indexOf(">");
const rootTag = rootTagEnd >= 0 ? deduped.slice(0, rootTagEnd + 1) : deduped;
const patchedRootTag = rootTag.replace(
/data-start="[^"]*"/,
`data-start="${formatTimelineAttributeNumber(currentTime)}"`,
);
const withNewStart = patchedRootTag + deduped.slice(rootTagEnd + 1);
patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart);
} else {
patchedContent = insertAsSibling(
originalContent,
deduped,
payload.originSelector,
payload.originSelectorIndex,
);
}

domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: payload.kind === "timeline-clip" ? "Paste clip" : "Paste element",
kind: "timeline" as EditHistoryKind,
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});

reloadPreview();
showToast(payload.kind === "timeline-clip" ? "Pasted clip" : "Pasted element", "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to paste";
showToast(message);
}
}, [
activeCompPath,
domEditSaveTimestampRef,
recordEdit,
reloadPreview,
showToast,
writeProjectFile,
]);

const handleCut = useCallback(async (): Promise<boolean> => {
const copied = handleCopy();
if (!copied) return false;

const { selectedElementId, elements } = usePlayerStore.getState();
if (selectedElementId) {
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
if (element) {
await handleTimelineElementDelete(element);
return true;
}
}

const domSelection = domEditSelectionRef.current;
if (domSelection) {
await handleDomEditElementDelete(domSelection);
return true;
}
return true;
}, [handleCopy, domEditSelectionRef, handleTimelineElementDelete, handleDomEditElementDelete]);

return { handleCopy, handlePaste, handleCut };
}
Loading
Loading