Skip to content

Commit 33cf5c3

Browse files
davide-donaEtienneLescot
authored andcommitted
feat(video-editor): region copy/paste and shared text-editing guard
Add copy/paste of region attributes (zoom/speed/annotation) with region placement helpers, and lift isTextEditingTarget into src/lib/shortcuts.ts so VideoEditor and TimelineEditor share one keyboard guard. Includes i18n strings for the new actions.
1 parent 5293b99 commit 33cf5c3

20 files changed

Lines changed: 280 additions & 155 deletions

src/components/video-editor/VideoEditor.tsx

Lines changed: 90 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import {
5151
} from "@/lib/exporter";
5252
import { computeFrameStepTime } from "@/lib/frameStep";
5353
import type { CursorCaptureMode, ProjectMedia } from "@/lib/recordingSession";
54-
import { matchesShortcut } from "@/lib/shortcuts";
54+
import { isTextEditingTarget, matchesShortcut } from "@/lib/shortcuts";
5555
import {
5656
getExportFolder,
5757
getProjectFolder,
@@ -88,15 +88,18 @@ import {
8888
validateProjectData,
8989
} from "./projectPersistence";
9090
import {
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";
100103
import { SettingsPanel } from "./SettingsPanel";
101104
import TimelineEditor from "./timeline/TimelineEditor";
102105
import { 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

Comments
 (0)