@@ -51,7 +51,7 @@ import {
5151} from "@/lib/exporter" ;
5252import { computeFrameStepTime } from "@/lib/frameStep" ;
5353import type { CursorCaptureMode , ProjectMedia } from "@/lib/recordingSession" ;
54- import { matchesShortcut } from "@/lib/shortcuts" ;
54+ import { isTextEditingTarget , matchesShortcut } from "@/lib/shortcuts" ;
5555import {
5656 getExportFolder ,
5757 getProjectFolder ,
@@ -88,15 +88,18 @@ import {
8888 validateProjectData ,
8989} from "./projectPersistence" ;
9090import {
91- applyAnnotationAttributes ,
92- applySpeedAttributes ,
93- applyZoomAttributes ,
9491 buildPastedAnnotation ,
92+ buildSpeedRegion ,
93+ buildZoomRegion ,
9594 type CopiedRegion ,
9695 extractAnnotationAttributes ,
9796 extractSpeedAttributes ,
9897 extractZoomAttributes ,
98+ getCopiedRegion ,
99+ replaceAnnotationAttributes ,
100+ setCopiedRegion ,
99101} from "./regionClipboard" ;
102+ import { findFreeGapAt } from "./regionPlacement" ;
100103import { SettingsPanel } from "./SettingsPanel" ;
101104import TimelineEditor from "./timeline/TimelineEditor" ;
102105import { buildAutoZoomSuggestions } from "./timeline/zoomSuggestionUtils" ;
@@ -310,9 +313,6 @@ export default function VideoEditor() {
310313 const nextTrimIdRef = useRef ( 1 ) ;
311314 const nextSpeedIdRef = useRef ( 1 ) ;
312315
313- // Session clipboard for "copy/paste region attributes" (not undoable, not persisted).
314- const regionClipboardRef = useRef < CopiedRegion | null > ( null ) ;
315-
316316 const { shortcuts, isMac } = useShortcuts ( ) ;
317317 // Windows recordings include captured cursor assets. macOS hides the system
318318 // cursor in ScreenCaptureKit and renders telemetry samples with OpenScreen's
@@ -326,6 +326,7 @@ export default function VideoEditor() {
326326 const { locale, setLocale, t : rawT } = useI18n ( ) ;
327327 const t = useScopedT ( "editor" ) ;
328328 const ts = useScopedT ( "settings" ) ;
329+ const tt = useScopedT ( "timeline" ) ;
329330 const availableLocales = getAvailableLocales ( ) ;
330331
331332 const nextAnnotationIdRef = useRef ( 1 ) ;
@@ -1654,29 +1655,28 @@ export default function VideoEditor() {
16541655 ) ;
16551656
16561657 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 ) ;
1658+ // Copy the selected region of any kind into the clipboard. A selected blur is an
1659+ // annotation (type "blur" lives in annotationRegions), so it copies via that row.
1660+ const copyTargets = [
1661+ [ selectedZoomId , zoomRegions , extractZoomAttributes , "zoom" ] ,
1662+ [ selectedSpeedId , speedRegions , extractSpeedAttributes , "speed" ] ,
1663+ [
1664+ selectedAnnotationId ?? selectedBlurId ,
1665+ annotationRegions ,
1666+ extractAnnotationAttributes ,
1667+ "annotation" ,
1668+ ] ,
1669+ ] as const ;
1670+
1671+ for ( const [ id , regions , extract , kind ] of copyTargets ) {
1672+ if ( ! id ) continue ;
1673+ const region = ( regions as readonly { id : string } [ ] ) . find ( ( r ) => r . id === id ) ;
16751674 if ( region ) {
1676- regionClipboardRef . current = extractAnnotationAttributes ( region ) ;
1677- toast . success (
1678- t ( "regionClipboard.copied" , { region : t ( "regionClipboard.kinds.annotation" ) } ) ,
1679- ) ;
1675+ // Each row pairs a region list with its matching extractor, so the cast is sound.
1676+ setCopiedRegion ( ( extract as ( r : never ) => CopiedRegion ) ( region as never ) ) ;
1677+ toast . success ( t ( "regionClipboard.copied" , { region : t ( `regionClipboard.kinds.${ kind } ` ) } ) , {
1678+ id : "regionClipboard.copied" ,
1679+ } ) ;
16801680 }
16811681 return ;
16821682 }
@@ -1685,49 +1685,64 @@ export default function VideoEditor() {
16851685 selectedZoomId ,
16861686 selectedSpeedId ,
16871687 selectedAnnotationId ,
1688+ selectedBlurId ,
16881689 zoomRegions ,
16891690 speedRegions ,
16901691 annotationRegions ,
16911692 t ,
16921693 ] ) ;
16931694
16941695 const handlePaste = useCallback ( ( ) => {
1695- const copied = regionClipboardRef . current ;
1696+ const copied = getCopiedRegion ( ) ;
16961697 // If there's nothing in the clipboard, show a message and return early.
16971698 if ( ! copied ) {
16981699 toast . info ( t ( "regionClipboard.nothingToPaste" ) ) ;
16991700 return ;
17001701 }
17011702
1702- const regionLabel = t ( `regionClipboard.kinds.${ copied . kind } ` ) ;
1703- const pastedToast = ( ) => toast . success ( t ( "regionClipboard.pasted" , { region : regionLabel } ) ) ;
1704-
17051703 // Apply onto the selected region of the same kind, keeping its timing.
17061704 if ( copied . kind === "zoom" && selectedZoomId ) {
17071705 pushState ( ( prev ) => ( {
17081706 zoomRegions : prev . zoomRegions . map ( ( r ) =>
1709- r . id === selectedZoomId ? applyZoomAttributes ( r , copied ) : r ,
1707+ r . id === selectedZoomId ? buildZoomRegion ( r , copied ) : r ,
17101708 ) ,
17111709 } ) ) ;
1712- pastedToast ( ) ;
1710+ toast . success (
1711+ t ( "regionClipboard.pasted" , { region : t ( `regionClipboard.kinds.${ copied . kind } ` ) } ) ,
1712+ {
1713+ id : "regionClipboard.pasted" ,
1714+ } ,
1715+ ) ;
17131716 return ;
17141717 }
17151718 if ( copied . kind === "speed" && selectedSpeedId ) {
17161719 pushState ( ( prev ) => ( {
17171720 speedRegions : prev . speedRegions . map ( ( r ) =>
1718- r . id === selectedSpeedId ? applySpeedAttributes ( r , copied ) : r ,
1721+ r . id === selectedSpeedId ? buildSpeedRegion ( r , copied ) : r ,
17191722 ) ,
17201723 } ) ) ;
1721- pastedToast ( ) ;
1724+ toast . success (
1725+ t ( "regionClipboard.pasted" , { region : t ( `regionClipboard.kinds.${ copied . kind } ` ) } ) ,
1726+ {
1727+ id : "regionClipboard.pasted" ,
1728+ } ,
1729+ ) ;
17221730 return ;
17231731 }
1724- if ( copied . kind === "annotation" && selectedAnnotationId ) {
1732+ // Blurs live in annotationRegions (type "blur"), so a selected blur is a valid target too.
1733+ if ( copied . kind === "annotation" && ( selectedAnnotationId || selectedBlurId ) ) {
1734+ const targetId = selectedAnnotationId ?? selectedBlurId ;
17251735 pushState ( ( prev ) => ( {
17261736 annotationRegions : prev . annotationRegions . map ( ( r ) =>
1727- r . id === selectedAnnotationId ? applyAnnotationAttributes ( r , copied ) : r ,
1737+ r . id === targetId ? replaceAnnotationAttributes ( r , copied ) : r ,
17281738 ) ,
17291739 } ) ) ;
1730- pastedToast ( ) ;
1740+ toast . success (
1741+ t ( "regionClipboard.pasted" , { region : t ( `regionClipboard.kinds.${ copied . kind } ` ) } ) ,
1742+ {
1743+ id : "regionClipboard.pasted" ,
1744+ } ,
1745+ ) ;
17311746 return ;
17321747 }
17331748
@@ -1738,56 +1753,59 @@ export default function VideoEditor() {
17381753 const startPos = Math . max ( 0 , Math . min ( Math . round ( currentTime * 1000 ) , totalMs ) ) ;
17391754
17401755 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" ) ) ;
1756+ const { ok, gapMs } = findFreeGapAt ( zoomRegions , startPos , totalMs ) ;
1757+ if ( ! ok ) {
1758+ toast . error ( tt ( "errors.cannotPlaceZoom" ) , {
1759+ description : tt ( "errors.zoomExistsAtLocation" ) ,
1760+ } ) ;
17481761 return ;
17491762 }
17501763 const id = `zoom-${ nextZoomIdRef . current ++ } ` ;
1751- const region = applyZoomAttributes (
1764+ const region = buildZoomRegion (
17521765 {
17531766 id,
17541767 startMs : startPos ,
1755- endMs : startPos + Math . min ( defaultDuration , gapToNext ) ,
1756- depth : DEFAULT_ZOOM_DEPTH ,
1757- focus : { cx : 0.5 , cy : 0.5 } ,
1768+ endMs : startPos + Math . min ( defaultDuration , gapMs ) ,
17581769 source : "manual" ,
17591770 } ,
17601771 copied ,
17611772 ) ;
17621773 pushState ( ( prev ) => ( { zoomRegions : [ ...prev . zoomRegions , region ] } ) ) ;
17631774 handleSelectZoom ( id ) ;
1764- pastedToast ( ) ;
1775+ toast . success (
1776+ t ( "regionClipboard.pasted" , { region : t ( `regionClipboard.kinds.${ copied . kind } ` ) } ) ,
1777+ {
1778+ id : "regionClipboard.pasted" ,
1779+ } ,
1780+ ) ;
17651781 return ;
17661782 }
17671783
17681784 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" ) ) ;
1785+ const { ok, gapMs } = findFreeGapAt ( speedRegions , startPos , totalMs ) ;
1786+ if ( ! ok ) {
1787+ toast . error ( tt ( "errors.cannotPlaceSpeed" ) , {
1788+ description : tt ( "errors.speedExistsAtLocation" ) ,
1789+ } ) ;
17761790 return ;
17771791 }
17781792 const id = `speed-${ nextSpeedIdRef . current ++ } ` ;
1779- const region = applySpeedAttributes (
1793+ const region = buildSpeedRegion (
17801794 {
17811795 id,
17821796 startMs : startPos ,
1783- endMs : startPos + Math . min ( defaultDuration , gapToNext ) ,
1784- speed : DEFAULT_PLAYBACK_SPEED ,
1797+ endMs : startPos + Math . min ( defaultDuration , gapMs ) ,
17851798 } ,
17861799 copied ,
17871800 ) ;
17881801 pushState ( ( prev ) => ( { speedRegions : [ ...prev . speedRegions , region ] } ) ) ;
17891802 handleSelectSpeed ( id ) ;
1790- pastedToast ( ) ;
1803+ toast . success (
1804+ t ( "regionClipboard.pasted" , { region : t ( `regionClipboard.kinds.${ copied . kind } ` ) } ) ,
1805+ {
1806+ id : "regionClipboard.pasted" ,
1807+ } ,
1808+ ) ;
17911809 return ;
17921810 }
17931811
@@ -1805,11 +1823,17 @@ export default function VideoEditor() {
18051823 ) ;
18061824 pushState ( ( prev ) => ( { annotationRegions : [ ...prev . annotationRegions , region ] } ) ) ;
18071825 handleSelectAnnotation ( id ) ;
1808- pastedToast ( ) ;
1826+ toast . success (
1827+ t ( "regionClipboard.pasted" , { region : t ( `regionClipboard.kinds.${ copied . kind } ` ) } ) ,
1828+ {
1829+ id : "regionClipboard.pasted" ,
1830+ } ,
1831+ ) ;
18091832 } , [
18101833 selectedZoomId ,
18111834 selectedSpeedId ,
18121835 selectedAnnotationId ,
1836+ selectedBlurId ,
18131837 zoomRegions ,
18141838 speedRegions ,
18151839 duration ,
@@ -1819,6 +1843,7 @@ export default function VideoEditor() {
18191843 handleSelectSpeed ,
18201844 handleSelectAnnotation ,
18211845 t ,
1846+ tt ,
18221847 ] ) ;
18231848
18241849 useEffect ( ( ) => {
@@ -1841,10 +1866,7 @@ export default function VideoEditor() {
18411866
18421867 // Copy/paste region attributes. Skipped while typing in a field so native
18431868 // 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 ) ;
1869+ const editingText = isTextEditingTarget ( e . target ) ;
18481870 if ( ! editingText ) {
18491871 if ( matchesShortcut ( e , shortcuts . copySelected , isMac ) ) {
18501872 e . preventDefault ( ) ;
0 commit comments