diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index a9a4b570c..5a8553c01 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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"; @@ -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({ @@ -162,15 +159,28 @@ export function StudioApp() { const clearDomSelectionRef = useRef<() => void>(() => {}); const domEditSelectionBridgeRef = useRef(null); - const handleDomEditElementDeleteRef = useRef<(selection: DomEditSelection) => Promise>( + const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise>( 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, @@ -182,6 +192,9 @@ export function StudioApp() { syncHistoryPreviewAfterApply: manifestPersistence.syncHistoryPreviewAfterApply, waitForPendingDomEditSaves: manifestPersistence.waitForPendingDomEditSaves, leftSidebarRef, + handleCopy, + handlePaste, + handleCut, }); const domEditSession = useDomEditSession({ diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index b553a8d19..80f07d8f3 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -55,6 +55,7 @@ export function StudioRightPanel({ copiedAgentPrompt, clearDomSelection, handleDomStyleCommit, + handleDomAttributeCommit, handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, @@ -168,6 +169,7 @@ export function StudioRightPanel({ copiedAgentPrompt={copiedAgentPrompt} onClearSelection={clearDomSelection} onSetStyle={handleDomStyleCommit} + onSetAttribute={handleDomAttributeCommit} onSetManualOffset={handleDomPathOffsetCommit} onSetManualSize={handleDomBoxSizeCommit} onSetManualRotation={handleDomRotationCommit} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 73947bf61..db8d2fea1 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons"; +import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons"; import { collectDomEditLayerItems, getDomEditLayerKey, @@ -39,6 +39,7 @@ interface PropertyPanelProps { copiedAgentPrompt: boolean; onClearSelection: () => void; onSetStyle: (prop: string, value: string) => void | Promise; + onSetAttribute: (attr: string, value: string) => void | Promise; onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void; onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void; onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void; @@ -114,6 +115,68 @@ function LayerTree({ ); } +/* ------------------------------------------------------------------ */ +/* TimingSection */ +/* ------------------------------------------------------------------ */ + +function formatTimingValue(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0.00s"; + return `${seconds.toFixed(2)}s`; +} + +function parseTimingValue(input: string): number | null { + const cleaned = input.replace(/s$/i, "").trim(); + const parsed = Number.parseFloat(cleaned); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +function TimingSection({ + element, + onSetAttribute, +}: { + element: DomEditSelection; + onSetAttribute: (attr: string, value: string) => void | Promise; +}) { + const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0; + const duration = Number.parseFloat(element.dataAttributes.duration ?? "0") || 0; + const end = start + duration; + + const commitStart = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null) return; + void onSetAttribute("start", parsed.toFixed(2)); + }; + + const commitDuration = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null || parsed <= 0) return; + void onSetAttribute("duration", parsed.toFixed(2)); + }; + + const commitEnd = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null || parsed <= start) return; + void onSetAttribute("duration", (parsed - start).toFixed(2)); + }; + + return ( +
}> +
+ + +
+
+ +
+
+ ); +} + /* ------------------------------------------------------------------ */ /* PropertyPanel */ /* ------------------------------------------------------------------ */ @@ -126,6 +189,7 @@ export const PropertyPanel = memo(function PropertyPanel({ copiedAgentPrompt, onClearSelection, onSetStyle, + onSetAttribute, onSetManualOffset, onSetManualSize, onSetManualRotation, @@ -322,6 +386,10 @@ export const PropertyPanel = memo(function PropertyPanel({ + {element.dataAttributes.start != null && ( + + )} + {showEditableSections && ( 0) { - return childFields.map((child, index) => - buildTextField(child, index, childFields.length, "child"), + const childElements = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf); + + if (childElements.length > 0) { + const hasMixedContent = Array.from(el.childNodes).some( + (node) => node.nodeType === 3 && node.textContent?.trim(), + ); + + if (hasMixedContent) { + const fields: DomEditTextField[] = []; + let childIdx = 0; + for (const node of el.childNodes) { + if (node.nodeType === 3) { + const text = node.textContent ?? ""; + if (!text.trim()) continue; + fields.push({ + key: `text-node:${childIdx}`, + label: `Text ${childIdx + 1}`, + value: text, + tagName: "#text", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "text-node", + }); + childIdx++; + } else if (isHtmlElement(node) && isEditableTextLeaf(node)) { + fields.push(buildTextField(node, childIdx, childElements.length, "child")); + childIdx++; + } + } + return fields; + } + + return childElements.map((child, index) => + buildTextField(child, index, childElements.length, "child"), ); } @@ -99,8 +130,11 @@ function serializeTextFieldStyle(field: DomEditTextField): string { export function serializeDomEditTextFields(fields: DomEditTextField[]): string { return fields - .filter((field) => field.source === "child") + .filter((field) => field.source === "child" || field.source === "text-node") .map((field) => { + if (field.source === "text-node") { + return escapeHtmlText(field.value); + } const attrs = [ ...field.attributes.filter((attribute) => attribute.name !== "data-hf-text-key"), { name: "data-hf-text-key", value: field.key }, diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts index e139395cb..da7b36e49 100644 --- a/packages/studio/src/components/editor/domEditingTypes.ts +++ b/packages/studio/src/components/editor/domEditingTypes.ts @@ -65,7 +65,7 @@ export interface DomEditTextField { attributes: Array<{ name: string; value: string }>; inlineStyles: Record; computedStyles: Record; - source: "self" | "child"; + source: "self" | "child" | "text-node"; } export interface DomEditSelection extends PatchTarget { diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index e76b23dd2..e211eab0e 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -99,7 +99,7 @@ export const NLELayout = memo(function NLELayout({ togglePlay, seek, onIframeLoad: baseOnIframeLoad, - saveSeekPosition, + refreshPlayer, } = useTimelinePlayer(); // Reset timeline state when the project changes @@ -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(); diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 8be285035..c655f6e9b 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -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, @@ -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(null); const stageRef = useRef(null); - const [retiringKey, setRetiringKey] = useState(null); - const retiringTimerRef = useRef | null>(null); const zoomRef = useRef(loadInitialZoom()); const hudRef = useRef(null); @@ -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); }; }, []); @@ -130,14 +128,6 @@ 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) { @@ -145,16 +135,6 @@ export const NLEPreview = memo(function NLEPreview({ } }, [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; @@ -282,32 +262,17 @@ export const NLEPreview = memo(function NLEPreview({ }} data-testid="preview-zoom-stage" > - {retiringKey && ( - {}} - portrait={portrait} - style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }} - /> - )} { - onIframeLoad(); - applyInitialZoom(); - } - } + onLoad={() => { + onIframeLoad(); + applyInitialZoom(); + }} onCompositionLoadingChange={onCompositionLoadingChange} portrait={portrait} - style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined} suppressLoadingOverlay={suppressLoadingOverlay} /> diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index d6aba5d6a..ad98777b8 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -28,6 +28,7 @@ export function DomEditProvider({ applyDomSelection, clearDomSelection, handleDomStyleCommit, + handleDomAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, @@ -74,6 +75,7 @@ export function DomEditProvider({ applyDomSelection, clearDomSelection, handleDomStyleCommit, + handleDomAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, @@ -114,6 +116,7 @@ export function DomEditProvider({ applyDomSelection, clearDomSelection, handleDomStyleCommit, + handleDomAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index b264ce42d..338147b76 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -45,6 +45,9 @@ interface UseAppHotkeysParams { syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise; waitForPendingDomEditSaves: () => Promise; leftSidebarRef: React.RefObject; + handleCopy: () => boolean; + handlePaste: () => Promise; + handleCut: () => Promise; } // ── Hook ── @@ -64,6 +67,9 @@ export function useAppHotkeys({ syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, leftSidebarRef, + handleCopy, + handlePaste, + handleCut, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); @@ -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 ── @@ -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) diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts new file mode 100644 index 000000000..e8fa8aae9 --- /dev/null +++ b/packages/studio/src/hooks/useClipboard.ts @@ -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; +} + +interface UseClipboardOptions { + projectId: string | null; + activeCompPath: string | null; + domEditSelectionRef: React.MutableRefObject; + showToast: (message: string, tone?: "error" | "info") => void; + writeProjectFile: (path: string, content: string) => Promise; + recordEdit: (input: RecordEditInput) => Promise; + domEditSaveTimestampRef: React.MutableRefObject; + reloadPreview: () => void; + handleTimelineElementDelete: (element: TimelineElement) => Promise; + handleDomEditElementDelete: (selection: DomEditSelection) => Promise; + previewIframeRef: React.MutableRefObject; +} + +async function readFileContent(projectId: string, targetPath: string): Promise { + const response = await fetch( + `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`, + ); + 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, + 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(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 }; +} diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 8c838b66c..c0458e0b6 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -189,6 +189,7 @@ export function useDomEditCommits({ const { handleDomStyleCommit, + handleDomAttributeCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, @@ -437,6 +438,7 @@ export function useDomEditCommits({ return { resolveImportedFontAsset, handleDomStyleCommit, + handleDomAttributeCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 61e37553c..46d29c393 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -193,6 +193,7 @@ export function useDomEditSession({ const { resolveImportedFontAsset, handleDomStyleCommit, + handleDomAttributeCommit, handleDomTextCommit, handleDomTextFieldStyleCommit, handleDomAddTextField, @@ -305,6 +306,7 @@ export function useDomEditSession({ applyDomSelection, clearDomSelection, handleDomStyleCommit, + handleDomAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, diff --git a/packages/studio/src/hooks/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts index 429ddbaaa..7b78b24d3 100644 --- a/packages/studio/src/hooks/useDomEditTextCommits.ts +++ b/packages/studio/src/hooks/useDomEditTextCommits.ts @@ -113,6 +113,38 @@ export function useDomEditTextCommits({ ], ); + const handleDomAttributeCommit = useCallback( + async (attr: string, value: string) => { + if (!domEditSelection) return; + const iframe = previewIframeRef.current; + const doc = iframe?.contentDocument; + if (doc) { + const el = findElementForSelection(doc, domEditSelection, activeCompPath); + if (el) el.setAttribute(`data-${attr}`, value); + } + const op: PatchOperation = { type: "attribute", property: attr, value }; + try { + await persistDomEditOperations(domEditSelection, [op], { + label: "Edit timing", + skipRefresh: false, + }); + } catch (err) { + console.warn( + "[Studio] Attribute persist failed:", + err instanceof Error ? err.message : err, + ); + } + refreshDomEditSelectionFromPreview(domEditSelection); + }, + [ + activeCompPath, + domEditSelection, + persistDomEditOperations, + refreshDomEditSelectionFromPreview, + previewIframeRef, + ], + ); + const handleDomTextCommit = useCallback( async (value: string, fieldKey?: string) => { if (!domEditSelection) return; @@ -321,6 +353,7 @@ export function useDomEditTextCommits({ return { handleDomStyleCommit, + handleDomAttributeCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 2ecadc0b4..47d967371 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -229,13 +229,19 @@ export const Player = forwardRef( // data arrives), but the overlay communicates why the first frame // or first audio beat may lag. // + // Skip the overlay on subsequent loads (content refreshes via + // refreshPlayer). The browser has already cached the assets from + // the first load, so they resolve near-instantly and the overlay + // just creates a disruptive flash. + // // Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap // trips we hide the overlay so the UI doesn't appear stuck forever, // but we log a debug warning so the case is diagnosable — a long // cold video or a broken asset can legitimately exceed 10 s on a // slow network. if (assetPollRef.current) clearInterval(assetPollRef.current); - let lastUnloaded = hasUnloadedAssets(iframe, false); + const isContentRefresh = loadCountRef.current > 1; + let lastUnloaded = isContentRefresh ? false : hasUnloadedAssets(iframe, false); if (lastUnloaded) { setAssetsLoading(true); let attempts = 0; diff --git a/packages/studio/src/utils/clipboardPayload.test.ts b/packages/studio/src/utils/clipboardPayload.test.ts new file mode 100644 index 000000000..a46073faa --- /dev/null +++ b/packages/studio/src/utils/clipboardPayload.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { + deduplicateIds, + serializeClipboardPayload, + deserializeClipboardPayload, + type ClipboardPayload, +} from "./clipboardPayload"; + +describe("deduplicateIds", () => { + it("renames ids that collide with existing ids", () => { + const html = '
'; + const existingIds = ["hero", "other"]; + const result = deduplicateIds(html, existingIds); + expect(result).not.toContain('id="hero"'); + expect(result).toContain('id="photo"'); + expect(result).toMatch(/id="hero-\d+"/); + }); + + it("returns html unchanged when no collisions", () => { + const html = '

hello

'; + const result = deduplicateIds(html, ["other"]); + expect(result).toBe(html); + }); +}); + +describe("serializeClipboardPayload / deserializeClipboardPayload", () => { + it("round-trips a timeline clip payload", () => { + const payload: ClipboardPayload = { + kind: "timeline-clip", + html: '', + sourceFile: "index.html", + }; + const json = serializeClipboardPayload(payload); + const parsed = deserializeClipboardPayload(json); + expect(parsed).toEqual(payload); + }); + + it("round-trips a dom-element payload", () => { + const payload: ClipboardPayload = { + kind: "dom-element", + html: '

Hello

', + sourceFile: "compositions/scene.html", + }; + const json = serializeClipboardPayload(payload); + const parsed = deserializeClipboardPayload(json); + expect(parsed).toEqual(payload); + }); + + it("returns null for invalid JSON", () => { + expect(deserializeClipboardPayload("not json")).toBeNull(); + expect(deserializeClipboardPayload('{"kind":"unknown"}')).toBeNull(); + }); +}); diff --git a/packages/studio/src/utils/clipboardPayload.ts b/packages/studio/src/utils/clipboardPayload.ts new file mode 100644 index 000000000..30b259938 --- /dev/null +++ b/packages/studio/src/utils/clipboardPayload.ts @@ -0,0 +1,168 @@ +const CLIPBOARD_MARKER = "hyperframes-clipboard:v1"; + +export interface ClipboardPayload { + kind: "timeline-clip" | "dom-element"; + html: string; + sourceFile: string; + originSelector?: string; + originSelectorIndex?: number; +} + +interface SerializedPayload { + _marker: string; + kind: "timeline-clip" | "dom-element"; + html: string; + sourceFile: string; + originSelector?: string; + originSelectorIndex?: number; +} + +export function serializeClipboardPayload(payload: ClipboardPayload): string { + const data: SerializedPayload = { + _marker: CLIPBOARD_MARKER, + kind: payload.kind, + html: payload.html, + sourceFile: payload.sourceFile, + originSelector: payload.originSelector, + originSelectorIndex: payload.originSelectorIndex, + }; + return JSON.stringify(data); +} + +export function deserializeClipboardPayload(json: string): ClipboardPayload | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const obj = parsed as Record; + if (obj._marker !== CLIPBOARD_MARKER) return null; + if (obj.kind !== "timeline-clip" && obj.kind !== "dom-element") return null; + if (typeof obj.html !== "string" || typeof obj.sourceFile !== "string") return null; + return { + kind: obj.kind, + html: obj.html, + sourceFile: obj.sourceFile, + originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined, + originSelectorIndex: + typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined, + }; +} + +/** + * Insert `newHtml` as a sibling immediately after the element matched by + * `selector` (at `selectorIndex`) in `source`. Falls back to inserting after + * the composition root if the selector doesn't match — so paste never silently + * drops the content. + */ +export function insertAsSibling( + source: string, + newHtml: string, + selector: string | undefined, + selectorIndex: number | undefined, +): string { + if (selector) { + const idx = selectorIndex ?? 0; + let matchCount = 0; + + // Find the element by searching for its opening tag pattern. + // For id selectors like #foo, search for id="foo". + // For class selectors like .name-text, search for class="...name-text...". + // For attribute selectors like [data-composition-id="x"], search literally. + + let searchPattern: RegExp | null = null; + if (selector.startsWith("#")) { + const id = selector.slice(1); + searchPattern = new RegExp(`<[a-z][^>]*\\bid="${id}"[^>]*>`, "gi"); + } else if (selector.startsWith(".")) { + const cls = selector.slice(1); + searchPattern = new RegExp(`<[a-z][^>]*\\bclass="[^"]*\\b${cls}\\b[^"]*"[^>]*>`, "gi"); + } else if (selector.startsWith("[")) { + const inner = selector.slice(1, -1); + searchPattern = new RegExp(`<[a-z][^>]*\\b${inner.replace(/"/g, '"')}[^>]*>`, "gi"); + } + + if (searchPattern) { + let match: RegExpExecArray | null; + while ((match = searchPattern.exec(source)) !== null) { + if (matchCount === idx) { + const insertPos = findClosingTagPosition(source, match.index); + if (insertPos > 0) { + return source.slice(0, insertPos) + "\n" + newHtml + source.slice(insertPos); + } + } + matchCount++; + } + } + } + + // Fallback: insert after composition root opening tag (same as timeline clips) + const rootOpenTag = /<[^>]*data-composition-id="[^"]+"[^>]*>/i; + const rootMatch = rootOpenTag.exec(source); + if (rootMatch && rootMatch.index != null) { + const insertAt = rootMatch.index + rootMatch[0].length; + return source.slice(0, insertAt) + newHtml + source.slice(insertAt); + } + + return source + newHtml; +} + +function findClosingTagPosition(html: string, openTagStart: number): number { + // Find the end of the opening tag + const openTagEnd = html.indexOf(">", openTagStart); + if (openTagEnd < 0) return -1; + + // Self-closing tag? + if (html[openTagEnd - 1] === "/") return openTagEnd + 1; + + // Extract the tag name + const tagNameMatch = html.slice(openTagStart).match(/^<([a-z][a-z0-9]*)/i); + if (!tagNameMatch) return -1; + const tagName = tagNameMatch[1]!; + + // Walk forward counting open/close tags of the same name + let depth = 1; + let pos = openTagEnd + 1; + const openRe = new RegExp(`<${tagName}(?:\\s|>|/>)`, "gi"); + const closeRe = new RegExp(``, "gi"); + + while (depth > 0 && pos < html.length) { + openRe.lastIndex = pos; + closeRe.lastIndex = pos; + + const nextOpen = openRe.exec(html); + const nextClose = closeRe.exec(html); + + if (!nextClose) return -1; + + if (nextOpen && nextOpen.index < nextClose.index) { + // Check if it's self-closing + const selfCloseCheck = html.lastIndexOf("/", html.indexOf(">", nextOpen.index)); + if (selfCloseCheck > nextOpen.index) { + pos = html.indexOf(">", nextOpen.index) + 1; + } else { + depth++; + pos = html.indexOf(">", nextOpen.index) + 1; + } + } else { + depth--; + if (depth === 0) return nextClose.index + nextClose[0].length; + pos = nextClose.index + nextClose[0].length; + } + } + return -1; +} + +export function deduplicateIds(html: string, existingIds: string[]): string { + const existingSet = new Set(existingIds); + return html.replace(/\bid="([^"]+)"/g, (full, id: string) => { + if (!existingSet.has(id)) return full; + let counter = 2; + while (existingSet.has(`${id}-${counter}`)) counter++; + const newId = `${id}-${counter}`; + existingSet.add(newId); + return `id="${newId}"`; + }); +}