@@ -87,6 +87,16 @@ import {
8787 toFileUrl ,
8888 validateProjectData ,
8989} from "./projectPersistence" ;
90+ import {
91+ applyAnnotationAttributes ,
92+ applySpeedAttributes ,
93+ applyZoomAttributes ,
94+ buildPastedAnnotation ,
95+ type CopiedRegion ,
96+ extractAnnotationAttributes ,
97+ extractSpeedAttributes ,
98+ extractZoomAttributes ,
99+ } from "./regionClipboard" ;
90100import { SettingsPanel } from "./SettingsPanel" ;
91101import TimelineEditor from "./timeline/TimelineEditor" ;
92102import { buildAutoZoomSuggestions } from "./timeline/zoomSuggestionUtils" ;
@@ -300,6 +310,9 @@ export default function VideoEditor() {
300310 const nextTrimIdRef = useRef ( 1 ) ;
301311 const nextSpeedIdRef = useRef ( 1 ) ;
302312
313+ // Session clipboard for "copy/paste region attributes" (not undoable, not persisted).
314+ const regionClipboardRef = useRef < CopiedRegion | null > ( null ) ;
315+
303316 const { shortcuts, isMac } = useShortcuts ( ) ;
304317 // Windows recordings include captured cursor assets. macOS hides the system
305318 // cursor in ScreenCaptureKit and renders telemetry samples with OpenScreen's
@@ -1640,6 +1653,174 @@ export default function VideoEditor() {
16401653 [ pushState ] ,
16411654 ) ;
16421655
1656+ const handleCopySelected = useCallback ( ( ) => {
1657+ if ( selectedZoomId ) {
1658+ const region = zoomRegions . find ( ( r ) => r . id === selectedZoomId ) ;
1659+ if ( region ) {
1660+ regionClipboardRef . current = extractZoomAttributes ( region ) ;
1661+ toast . success ( t ( "regionClipboard.copied" , { region : t ( "regionClipboard.kinds.zoom" ) } ) ) ;
1662+ }
1663+ return ;
1664+ }
1665+ if ( selectedSpeedId ) {
1666+ const region = speedRegions . find ( ( r ) => r . id === selectedSpeedId ) ;
1667+ if ( region ) {
1668+ regionClipboardRef . current = extractSpeedAttributes ( region ) ;
1669+ toast . success ( t ( "regionClipboard.copied" , { region : t ( "regionClipboard.kinds.speed" ) } ) ) ;
1670+ }
1671+ return ;
1672+ }
1673+ if ( selectedAnnotationId ) {
1674+ const region = annotationRegions . find ( ( r ) => r . id === selectedAnnotationId ) ;
1675+ if ( region ) {
1676+ regionClipboardRef . current = extractAnnotationAttributes ( region ) ;
1677+ toast . success (
1678+ t ( "regionClipboard.copied" , { region : t ( "regionClipboard.kinds.annotation" ) } ) ,
1679+ ) ;
1680+ }
1681+ return ;
1682+ }
1683+ toast . info ( t ( "regionClipboard.nothingToCopy" ) ) ;
1684+ } , [
1685+ selectedZoomId ,
1686+ selectedSpeedId ,
1687+ selectedAnnotationId ,
1688+ zoomRegions ,
1689+ speedRegions ,
1690+ annotationRegions ,
1691+ t ,
1692+ ] ) ;
1693+
1694+ const handlePaste = useCallback ( ( ) => {
1695+ const copied = regionClipboardRef . current ;
1696+ // If there's nothing in the clipboard, show a message and return early.
1697+ if ( ! copied ) {
1698+ toast . info ( t ( "regionClipboard.nothingToPaste" ) ) ;
1699+ return ;
1700+ }
1701+
1702+ const regionLabel = t ( `regionClipboard.kinds.${ copied . kind } ` ) ;
1703+ const pastedToast = ( ) => toast . success ( t ( "regionClipboard.pasted" , { region : regionLabel } ) ) ;
1704+
1705+ // Apply onto the selected region of the same kind, keeping its timing.
1706+ if ( copied . kind === "zoom" && selectedZoomId ) {
1707+ pushState ( ( prev ) => ( {
1708+ zoomRegions : prev . zoomRegions . map ( ( r ) =>
1709+ r . id === selectedZoomId ? applyZoomAttributes ( r , copied ) : r ,
1710+ ) ,
1711+ } ) ) ;
1712+ pastedToast ( ) ;
1713+ return ;
1714+ }
1715+ if ( copied . kind === "speed" && selectedSpeedId ) {
1716+ pushState ( ( prev ) => ( {
1717+ speedRegions : prev . speedRegions . map ( ( r ) =>
1718+ r . id === selectedSpeedId ? applySpeedAttributes ( r , copied ) : r ,
1719+ ) ,
1720+ } ) ) ;
1721+ pastedToast ( ) ;
1722+ return ;
1723+ }
1724+ if ( copied . kind === "annotation" && selectedAnnotationId ) {
1725+ pushState ( ( prev ) => ( {
1726+ annotationRegions : prev . annotationRegions . map ( ( r ) =>
1727+ r . id === selectedAnnotationId ? applyAnnotationAttributes ( r , copied ) : r ,
1728+ ) ,
1729+ } ) ) ;
1730+ pastedToast ( ) ;
1731+ return ;
1732+ }
1733+
1734+ // Nothing matching selected → create a new region at the playhead.
1735+ const totalMs = Math . round ( duration * 1000 ) ;
1736+ if ( totalMs <= 0 ) return ;
1737+ const defaultDuration = Math . min ( Math . max ( 1000 , Math . round ( totalMs * 0.05 ) ) , totalMs ) ;
1738+ const startPos = Math . max ( 0 , Math . min ( Math . round ( currentTime * 1000 ) , totalMs ) ) ;
1739+
1740+ if ( copied . kind === "zoom" ) {
1741+ const sorted = [ ...zoomRegions ] . sort ( ( a , b ) => a . startMs - b . startMs ) ;
1742+ const nextRegion = sorted . find ( ( r ) => r . startMs > startPos ) ;
1743+ const gapToNext = nextRegion ? nextRegion . startMs - startPos : totalMs - startPos ;
1744+ const overlapping = sorted . some ( ( r ) => startPos >= r . startMs && startPos < r . endMs ) ;
1745+
1746+ if ( overlapping || gapToNext <= 0 ) {
1747+ toast . error ( t ( "regionClipboard.cannotPlace" ) ) ;
1748+ return ;
1749+ }
1750+ const id = `zoom-${ nextZoomIdRef . current ++ } ` ;
1751+ const region = applyZoomAttributes (
1752+ {
1753+ id,
1754+ startMs : startPos ,
1755+ endMs : startPos + Math . min ( defaultDuration , gapToNext ) ,
1756+ depth : DEFAULT_ZOOM_DEPTH ,
1757+ focus : { cx : 0.5 , cy : 0.5 } ,
1758+ source : "manual" ,
1759+ } ,
1760+ copied ,
1761+ ) ;
1762+ pushState ( ( prev ) => ( { zoomRegions : [ ...prev . zoomRegions , region ] } ) ) ;
1763+ handleSelectZoom ( id ) ;
1764+ pastedToast ( ) ;
1765+ return ;
1766+ }
1767+
1768+ if ( copied . kind === "speed" ) {
1769+ const sorted = [ ...speedRegions ] . sort ( ( a , b ) => a . startMs - b . startMs ) ;
1770+ const nextRegion = sorted . find ( ( r ) => r . startMs > startPos ) ;
1771+ const gapToNext = nextRegion ? nextRegion . startMs - startPos : totalMs - startPos ;
1772+ const overlapping = sorted . some ( ( r ) => startPos >= r . startMs && startPos < r . endMs ) ;
1773+
1774+ if ( overlapping || gapToNext <= 0 ) {
1775+ toast . error ( t ( "regionClipboard.cannotPlace" ) ) ;
1776+ return ;
1777+ }
1778+ const id = `speed-${ nextSpeedIdRef . current ++ } ` ;
1779+ const region = applySpeedAttributes (
1780+ {
1781+ id,
1782+ startMs : startPos ,
1783+ endMs : startPos + Math . min ( defaultDuration , gapToNext ) ,
1784+ speed : DEFAULT_PLAYBACK_SPEED ,
1785+ } ,
1786+ copied ,
1787+ ) ;
1788+ pushState ( ( prev ) => ( { speedRegions : [ ...prev . speedRegions , region ] } ) ) ;
1789+ handleSelectSpeed ( id ) ;
1790+ pastedToast ( ) ;
1791+ return ;
1792+ }
1793+
1794+ // Annotation — overlaps are allowed. A brand-new region clones the full copy
1795+ // (type, content, styling, position), unlike the styling-only overwrite above.
1796+ const id = `annotation-${ nextAnnotationIdRef . current ++ } ` ;
1797+ const region = buildPastedAnnotation (
1798+ {
1799+ id,
1800+ startMs : startPos ,
1801+ endMs : Math . min ( startPos + defaultDuration , totalMs ) ,
1802+ zIndex : nextAnnotationZIndexRef . current ++ ,
1803+ } ,
1804+ copied ,
1805+ ) ;
1806+ pushState ( ( prev ) => ( { annotationRegions : [ ...prev . annotationRegions , region ] } ) ) ;
1807+ handleSelectAnnotation ( id ) ;
1808+ pastedToast ( ) ;
1809+ } , [
1810+ selectedZoomId ,
1811+ selectedSpeedId ,
1812+ selectedAnnotationId ,
1813+ zoomRegions ,
1814+ speedRegions ,
1815+ duration ,
1816+ currentTime ,
1817+ pushState ,
1818+ handleSelectZoom ,
1819+ handleSelectSpeed ,
1820+ handleSelectAnnotation ,
1821+ t ,
1822+ ] ) ;
1823+
16431824 useEffect ( ( ) => {
16441825 const handleKeyDown = ( e : KeyboardEvent ) => {
16451826 const mod = e . ctrlKey || e . metaKey ;
@@ -1658,6 +1839,25 @@ export default function VideoEditor() {
16581839 return ;
16591840 }
16601841
1842+ // Copy/paste region attributes. Skipped while typing in a field so native
1843+ // text copy/paste keeps working.
1844+ const editingText =
1845+ e . target instanceof HTMLInputElement ||
1846+ e . target instanceof HTMLTextAreaElement ||
1847+ ( e . target instanceof HTMLElement && e . target . isContentEditable ) ;
1848+ if ( ! editingText ) {
1849+ if ( matchesShortcut ( e , shortcuts . copySelected , isMac ) ) {
1850+ e . preventDefault ( ) ;
1851+ handleCopySelected ( ) ;
1852+ return ;
1853+ }
1854+ if ( matchesShortcut ( e , shortcuts . paste , isMac ) ) {
1855+ e . preventDefault ( ) ;
1856+ handlePaste ( ) ;
1857+ return ;
1858+ }
1859+ }
1860+
16611861 // Frame-step navigation (arrow keys, no modifiers)
16621862 if (
16631863 ( e . key === "ArrowLeft" || e . key === "ArrowRight" ) &&
@@ -1714,7 +1914,7 @@ export default function VideoEditor() {
17141914
17151915 window . addEventListener ( "keydown" , handleKeyDown , { capture : true } ) ;
17161916 return ( ) => window . removeEventListener ( "keydown" , handleKeyDown , { capture : true } ) ;
1717- } , [ undo , redo , shortcuts , isMac ] ) ;
1917+ } , [ undo , redo , shortcuts , isMac , handleCopySelected , handlePaste ] ) ;
17181918
17191919 useEffect ( ( ) => {
17201920 if ( selectedZoomId && ! zoomRegions . some ( ( region ) => region . id === selectedZoomId ) ) {
0 commit comments