Skip to content
Closed
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
31 changes: 22 additions & 9 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 @@ -109,11 +110,7 @@ export function StudioApp() {
const editHistory = usePersistentEditHistory({ projectId });
const domEditSaveTimestampRef = useRef(0);
const reloadPreview = useCallback(() => {
try {
previewIframeRef.current?.contentWindow?.location.reload();
} catch {
setRefreshKey((k) => k + 1);
}
setRefreshKey((k) => k + 1);
}, []);

const fileManager = useFileManager({
Expand Down Expand Up @@ -162,15 +159,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 +192,9 @@ export function StudioApp() {
syncHistoryPreviewAfterApply: manifestPersistence.syncHistoryPreviewAfterApply,
waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves,
leftSidebarRef,
handleCopy,
handlePaste,
handleCut,
});

const domEditSession = useDomEditSession({
Expand Down
34 changes: 34 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<void>;
}

// ── 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,28 @@ export function useAppHotkeys({
leftSidebarRef.current?.selectTab("assets");
return;
}

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

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

// Cmd/Ctrl+X — cut
if (copyPasteKey === "x" && !event.shiftKey && !isEditableTarget(event.target)) {
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,
serializeClipboardPayload,
deduplicateIds,
insertAsSibling,
} from "../utils/clipboardPayload";
import { copyTextToClipboard } from "../utils/clipboard";
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
.
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;
void copyTextToClipboard(serializeClipboardPayload(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;
void copyTextToClipboard(serializeClipboardPayload(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") {
const { currentTime } = usePlayerStore.getState();
const withNewStart = deduped.replace(
/data-start="[^"]*"/,
`data-start="${formatTimelineAttributeNumber(currentTime)}"`,
);
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 () => {
const copied = handleCopy();
if (!copied) return;

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

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

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