Skip to content

feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements#887

Closed
miguel-heygen wants to merge 9 commits into
mainfrom
feat/studio-copy-paste
Closed

feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements#887
miguel-heygen wants to merge 9 commits into
mainfrom
feat/studio-copy-paste

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

Summary

  • Adds Ctrl+C / Ctrl+V / Ctrl+X keyboard shortcuts to the Studio for copying, pasting, and cutting timeline clips and DOM elements
  • Contextual dispatch: if a timeline clip is selected, operates on clips; if a DOM element is selected via the inspector, operates on DOM elements
  • Pasted timeline clips are re-timed to the current playhead position with deduplicated IDs
  • All paste/cut operations are fully undoable via Ctrl+Z
  • isEditableTarget guard prevents intercepting native copy/paste in text inputs

Implementation

  • clipboardPayload.ts — payload types, JSON serialization with version marker, deduplicateIds for collision-safe paste
  • useClipboard.ts — hook owning an in-memory clipboard ref + copy/paste/cut handlers. Reads element HTML from the preview iframe, inserts via insertTimelineAssetIntoSource, records edits via saveProjectFilesWithHistory
  • useAppHotkeys.ts — Ctrl+C/V/X wired into the consolidated keydown handler with stable refs
  • Cross-frame instanceof fix: elements from the preview iframe fail el instanceof HTMLElement (different window context), switched to "outerHTML" in el duck-typing

Test plan

  • 5 unit tests for clipboardPayload (dedup collisions, no-collision passthrough, round-trip serialization, invalid JSON rejection)
  • Build passes (studio + CLI)
  • Manual: select timeline clip → Ctrl+C → "Copied clip" toast → Ctrl+V → clip pasted at playhead with deduplicated ID
  • Manual: Ctrl+Z after paste → "Undid Paste clip" → clip removed
  • Manual: Ctrl+C/V in text inputs → native behavior preserved (not intercepted)

Elements from the preview iframe are from a different window context,
so `el instanceof HTMLElement` always returns false. Use `"outerHTML"
in el` instead to correctly detect elements across frame boundaries.
Comment on lines +39 to +41
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
);
reloadPreview() used location.reload() which bypassed the
NLELayout saveSeekPosition effect, causing the playhead to reset
to 0:00 after paste. Switch to setRefreshKey which triggers the
effect and restores the seek position after the iframe reloads.
DOM element paste was inserting at the composition root, losing the
parent context that provides CSS styles and positioning. Now stores
the origin selector on copy and inserts the paste as a sibling
immediately after the original element, preserving style inheritance.
Falls back to root insertion if the selector can't be matched.
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");
…rdown

Content refreshes (paste, move, resize, delete, asset drop) previously
triggered setRefreshKey which changed the Player's React key, causing
full web-component destruction + iframe teardown + crossfade animation
+ re-initialization of all event listeners and asset polling.

Now NLELayout intercepts refreshKey changes and calls refreshPlayer()
which just appends a cache-busting _t param to the iframe src. The
Player web component stays alive, event listeners persist, and the
reload is ~10x faster with no "waiting for media" flash.

Key-based teardown is preserved for actual structural changes (project
switch, composition drill-down via directUrl change).
The asset-loading overlay ("Preparing preview assets") polled for
video/audio readyState on every iframe load, including content
refreshes from paste/move/resize. On reloads the browser serves
assets from cache so they resolve near-instantly — the overlay
just created a disruptive flash. Now skips the polling on
subsequent loads (loadCountRef > 1), only showing it on the
initial cold load.
Adds Start, End, and Duration fields to the Design panel when the
selected element has data-start/data-duration attributes. Editing
any field commits via the attribute patch pipeline (same as timeline
edits) and refreshes the preview. End is computed from start+duration
and writing End adjusts duration accordingly.
collectDomEditTextFields only captured child HTML elements, ignoring
bare text nodes. For elements like:
  <div class="headline">If you're <span>turning 65</span> soon...</div>
only the <span> was collected as a text field. When commitDomTextFields
serialized back, "If you're " and " soon..." were lost.

Now walks childNodes and creates text-node fields for bare text nodes
alongside child element fields. serializeDomEditTextFields emits bare
text for text-node fields, preserving the complete mixed content.
@miguel-heygen miguel-heygen force-pushed the feat/studio-copy-paste branch from 25775d8 to f6202d3 Compare May 16, 2026 07:02
@miguel-heygen
Copy link
Copy Markdown
Collaborator Author

Splitting into stacked PRs for easier review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants