Skip to content

Commit 5293b99

Browse files
davide-donaEtienneLescot
authored andcommitted
First implementation of copy
1 parent 1790ccb commit 5293b99

30 files changed

Lines changed: 695 additions & 14 deletions

src/components/video-editor/VideoEditor.tsx

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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";
90100
import { SettingsPanel } from "./SettingsPanel";
91101
import TimelineEditor from "./timeline/TimelineEditor";
92102
import { 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)) {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
applyAnnotationAttributes,
4+
applySpeedAttributes,
5+
applyZoomAttributes,
6+
buildPastedAnnotation,
7+
extractAnnotationAttributes,
8+
extractSpeedAttributes,
9+
extractZoomAttributes,
10+
} from "./regionClipboard";
11+
import {
12+
type AnnotationRegion,
13+
DEFAULT_ANNOTATION_POSITION,
14+
DEFAULT_ANNOTATION_SIZE,
15+
DEFAULT_ANNOTATION_STYLE,
16+
DEFAULT_FIGURE_DATA,
17+
type SpeedRegion,
18+
type ZoomRegion,
19+
} from "./types";
20+
21+
const zoom: ZoomRegion = {
22+
id: "zoom-1",
23+
startMs: 1000,
24+
endMs: 3000,
25+
depth: 4,
26+
customScale: 2.75,
27+
focus: { cx: 0.2, cy: 0.8 },
28+
focusMode: "manual",
29+
rotationPreset: "iso",
30+
source: "manual",
31+
};
32+
33+
const speed: SpeedRegion = { id: "speed-1", startMs: 0, endMs: 500, speed: 2 };
34+
35+
const annotation: AnnotationRegion = {
36+
id: "annotation-1",
37+
startMs: 0,
38+
endMs: 2000,
39+
type: "figure",
40+
content: "hello",
41+
position: { x: 10, y: 90 },
42+
size: { width: 40, height: 25 },
43+
style: { ...DEFAULT_ANNOTATION_STYLE, color: "#ff0000", textAnimation: "pop" },
44+
zIndex: 3,
45+
figureData: { ...DEFAULT_FIGURE_DATA, color: "#123456" },
46+
};
47+
48+
describe("zoom attribute copy/paste", () => {
49+
it("round-trips the copyable attributes onto a different clip while keeping its identity/timing", () => {
50+
const attrs = extractZoomAttributes(zoom);
51+
const target: ZoomRegion = {
52+
id: "zoom-2",
53+
startMs: 9000,
54+
endMs: 9500,
55+
depth: 1,
56+
focus: { cx: 0.5, cy: 0.5 },
57+
source: "manual",
58+
};
59+
const result = applyZoomAttributes(target, attrs);
60+
61+
expect(result.id).toBe("zoom-2");
62+
expect(result.startMs).toBe(9000);
63+
expect(result.endMs).toBe(9500);
64+
expect(result.depth).toBe(4);
65+
expect(result.customScale).toBe(2.75);
66+
expect(result.focus).toEqual({ cx: 0.2, cy: 0.8 });
67+
expect(result.focusMode).toBe("manual");
68+
expect(result.rotationPreset).toBe("iso");
69+
});
70+
71+
it("deep-copies focus so the source and target are decoupled", () => {
72+
const attrs = extractZoomAttributes(zoom);
73+
const result = applyZoomAttributes({ ...zoom, id: "zoom-2" }, attrs);
74+
result.focus.cx = 0.99;
75+
expect(zoom.focus.cx).toBe(0.2);
76+
});
77+
});
78+
79+
describe("speed attribute copy/paste", () => {
80+
it("copies only the speed value", () => {
81+
const attrs = extractSpeedAttributes(speed);
82+
const target: SpeedRegion = { id: "speed-2", startMs: 4000, endMs: 5000, speed: 1 };
83+
const result = applySpeedAttributes(target, attrs);
84+
expect(result).toEqual({ id: "speed-2", startMs: 4000, endMs: 5000, speed: 2 });
85+
});
86+
});
87+
88+
describe("annotation copy captures everything", () => {
89+
it("captures styling plus content, type, and position", () => {
90+
const attrs = extractAnnotationAttributes(annotation);
91+
expect(attrs.type).toBe("figure");
92+
expect(attrs.content).toBe("hello");
93+
expect(attrs.position).toEqual({ x: 10, y: 90 });
94+
expect(attrs.style.color).toBe("#ff0000");
95+
expect(attrs.figureData?.color).toBe("#123456");
96+
});
97+
});
98+
99+
describe("paste onto an existing annotation applies styling only", () => {
100+
it("overwrites the look/feel but keeps the target's content, position, timing, and zIndex", () => {
101+
const attrs = extractAnnotationAttributes(annotation);
102+
const target: AnnotationRegion = {
103+
id: "annotation-2",
104+
startMs: 7000,
105+
endMs: 8000,
106+
type: "text",
107+
content: "world",
108+
position: { ...DEFAULT_ANNOTATION_POSITION },
109+
size: { ...DEFAULT_ANNOTATION_SIZE },
110+
style: { ...DEFAULT_ANNOTATION_STYLE },
111+
zIndex: 9,
112+
};
113+
const result = applyAnnotationAttributes(target, attrs);
114+
115+
expect(result.content).toBe("world");
116+
expect(result.position).toEqual(DEFAULT_ANNOTATION_POSITION);
117+
expect(result.startMs).toBe(7000);
118+
expect(result.zIndex).toBe(9);
119+
expect(result.style.color).toBe("#ff0000");
120+
expect(result.style.textAnimation).toBe("pop");
121+
expect(result.size).toEqual({ width: 40, height: 25 });
122+
expect(result.figureData?.color).toBe("#123456");
123+
});
124+
125+
it("keeps the target's own figure data when the copied region has none", () => {
126+
const textAttrs = extractAnnotationAttributes({ ...annotation, figureData: undefined });
127+
const figureTarget: AnnotationRegion = { ...annotation, id: "annotation-3" };
128+
const result = applyAnnotationAttributes(figureTarget, textAttrs);
129+
expect(result.figureData?.color).toBe("#123456");
130+
});
131+
});
132+
133+
describe("paste as a new annotation clones the full copy", () => {
134+
it("clones type, content, styling, and position; takes timing/identity from the base", () => {
135+
const attrs = extractAnnotationAttributes(annotation);
136+
const result = buildPastedAnnotation(
137+
{ id: "annotation-4", startMs: 12000, endMs: 14000, zIndex: 5 },
138+
attrs,
139+
);
140+
141+
expect(result.id).toBe("annotation-4");
142+
expect(result.startMs).toBe(12000);
143+
expect(result.endMs).toBe(14000);
144+
expect(result.zIndex).toBe(5);
145+
expect(result.type).toBe("figure");
146+
expect(result.content).toBe("hello");
147+
expect(result.position).toEqual({ x: 10, y: 90 });
148+
expect(result.style.color).toBe("#ff0000");
149+
expect(result.figureData?.color).toBe("#123456");
150+
});
151+
152+
it("deep-copies position so source and clone are decoupled", () => {
153+
const attrs = extractAnnotationAttributes(annotation);
154+
const result = buildPastedAnnotation(
155+
{ id: "annotation-5", startMs: 0, endMs: 1000, zIndex: 1 },
156+
attrs,
157+
);
158+
result.position.x = 99;
159+
expect(annotation.position.x).toBe(10);
160+
});
161+
});

0 commit comments

Comments
 (0)