From 3f40db29307a7ac9d11653fa8c9fb33777d5c770 Mon Sep 17 00:00:00 2001 From: 446f6e6e79 <66618414+446f6e6e79@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:34:50 +0200 Subject: [PATCH 1/4] First implementation of copy --- src/components/video-editor/VideoEditor.tsx | 202 +++++++++++++++++- .../video-editor/regionClipboard.test.ts | 161 ++++++++++++++ .../video-editor/regionClipboard.ts | 132 ++++++++++++ src/i18n/locales/ar/editor.json | 12 ++ src/i18n/locales/ar/shortcuts.json | 4 +- src/i18n/locales/en/editor.json | 12 ++ src/i18n/locales/en/shortcuts.json | 4 +- src/i18n/locales/es/editor.json | 12 ++ src/i18n/locales/es/shortcuts.json | 4 +- src/i18n/locales/fr/editor.json | 12 ++ src/i18n/locales/fr/shortcuts.json | 4 +- src/i18n/locales/it/editor.json | 12 ++ src/i18n/locales/it/shortcuts.json | 4 +- src/i18n/locales/ja-JP/editor.json | 12 ++ src/i18n/locales/ja-JP/shortcuts.json | 4 +- src/i18n/locales/ko-KR/editor.json | 12 ++ src/i18n/locales/ko-KR/shortcuts.json | 4 +- src/i18n/locales/pt-BR/editor.json | 12 ++ src/i18n/locales/pt-BR/shortcuts.json | 4 +- src/i18n/locales/ru/editor.json | 12 ++ src/i18n/locales/ru/shortcuts.json | 4 +- src/i18n/locales/tr/editor.json | 12 ++ src/i18n/locales/tr/shortcuts.json | 4 +- src/i18n/locales/vi/editor.json | 12 ++ src/i18n/locales/vi/shortcuts.json | 4 +- src/i18n/locales/zh-CN/editor.json | 12 ++ src/i18n/locales/zh-CN/shortcuts.json | 4 +- src/i18n/locales/zh-TW/editor.json | 12 ++ src/i18n/locales/zh-TW/shortcuts.json | 4 +- src/lib/shortcuts.ts | 6 + 30 files changed, 695 insertions(+), 14 deletions(-) create mode 100644 src/components/video-editor/regionClipboard.test.ts create mode 100644 src/components/video-editor/regionClipboard.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 445a09060..5f9ec0f26 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -87,6 +87,16 @@ import { toFileUrl, validateProjectData, } from "./projectPersistence"; +import { + applyAnnotationAttributes, + applySpeedAttributes, + applyZoomAttributes, + buildPastedAnnotation, + type CopiedRegion, + extractAnnotationAttributes, + extractSpeedAttributes, + extractZoomAttributes, +} from "./regionClipboard"; import { SettingsPanel } from "./SettingsPanel"; import TimelineEditor from "./timeline/TimelineEditor"; import { buildAutoZoomSuggestions } from "./timeline/zoomSuggestionUtils"; @@ -300,6 +310,9 @@ export default function VideoEditor() { const nextTrimIdRef = useRef(1); const nextSpeedIdRef = useRef(1); + // Session clipboard for "copy/paste region attributes" (not undoable, not persisted). + const regionClipboardRef = useRef(null); + const { shortcuts, isMac } = useShortcuts(); // Windows recordings include captured cursor assets. macOS hides the system // cursor in ScreenCaptureKit and renders telemetry samples with OpenScreen's @@ -1640,6 +1653,174 @@ export default function VideoEditor() { [pushState], ); + const handleCopySelected = useCallback(() => { + if (selectedZoomId) { + const region = zoomRegions.find((r) => r.id === selectedZoomId); + if (region) { + regionClipboardRef.current = extractZoomAttributes(region); + toast.success(t("regionClipboard.copied", { region: t("regionClipboard.kinds.zoom") })); + } + return; + } + if (selectedSpeedId) { + const region = speedRegions.find((r) => r.id === selectedSpeedId); + if (region) { + regionClipboardRef.current = extractSpeedAttributes(region); + toast.success(t("regionClipboard.copied", { region: t("regionClipboard.kinds.speed") })); + } + return; + } + if (selectedAnnotationId) { + const region = annotationRegions.find((r) => r.id === selectedAnnotationId); + if (region) { + regionClipboardRef.current = extractAnnotationAttributes(region); + toast.success( + t("regionClipboard.copied", { region: t("regionClipboard.kinds.annotation") }), + ); + } + return; + } + toast.info(t("regionClipboard.nothingToCopy")); + }, [ + selectedZoomId, + selectedSpeedId, + selectedAnnotationId, + zoomRegions, + speedRegions, + annotationRegions, + t, + ]); + + const handlePaste = useCallback(() => { + const copied = regionClipboardRef.current; + // If there's nothing in the clipboard, show a message and return early. + if (!copied) { + toast.info(t("regionClipboard.nothingToPaste")); + return; + } + + const regionLabel = t(`regionClipboard.kinds.${copied.kind}`); + const pastedToast = () => toast.success(t("regionClipboard.pasted", { region: regionLabel })); + + // Apply onto the selected region of the same kind, keeping its timing. + if (copied.kind === "zoom" && selectedZoomId) { + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((r) => + r.id === selectedZoomId ? applyZoomAttributes(r, copied) : r, + ), + })); + pastedToast(); + return; + } + if (copied.kind === "speed" && selectedSpeedId) { + pushState((prev) => ({ + speedRegions: prev.speedRegions.map((r) => + r.id === selectedSpeedId ? applySpeedAttributes(r, copied) : r, + ), + })); + pastedToast(); + return; + } + if (copied.kind === "annotation" && selectedAnnotationId) { + pushState((prev) => ({ + annotationRegions: prev.annotationRegions.map((r) => + r.id === selectedAnnotationId ? applyAnnotationAttributes(r, copied) : r, + ), + })); + pastedToast(); + return; + } + + // Nothing matching selected → create a new region at the playhead. + const totalMs = Math.round(duration * 1000); + if (totalMs <= 0) return; + const defaultDuration = Math.min(Math.max(1000, Math.round(totalMs * 0.05)), totalMs); + const startPos = Math.max(0, Math.min(Math.round(currentTime * 1000), totalMs)); + + if (copied.kind === "zoom") { + const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((r) => r.startMs > startPos); + const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + const overlapping = sorted.some((r) => startPos >= r.startMs && startPos < r.endMs); + + if (overlapping || gapToNext <= 0) { + toast.error(t("regionClipboard.cannotPlace")); + return; + } + const id = `zoom-${nextZoomIdRef.current++}`; + const region = applyZoomAttributes( + { + id, + startMs: startPos, + endMs: startPos + Math.min(defaultDuration, gapToNext), + depth: DEFAULT_ZOOM_DEPTH, + focus: { cx: 0.5, cy: 0.5 }, + source: "manual", + }, + copied, + ); + pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, region] })); + handleSelectZoom(id); + pastedToast(); + return; + } + + if (copied.kind === "speed") { + const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((r) => r.startMs > startPos); + const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + const overlapping = sorted.some((r) => startPos >= r.startMs && startPos < r.endMs); + + if (overlapping || gapToNext <= 0) { + toast.error(t("regionClipboard.cannotPlace")); + return; + } + const id = `speed-${nextSpeedIdRef.current++}`; + const region = applySpeedAttributes( + { + id, + startMs: startPos, + endMs: startPos + Math.min(defaultDuration, gapToNext), + speed: DEFAULT_PLAYBACK_SPEED, + }, + copied, + ); + pushState((prev) => ({ speedRegions: [...prev.speedRegions, region] })); + handleSelectSpeed(id); + pastedToast(); + return; + } + + // Annotation — overlaps are allowed. A brand-new region clones the full copy + // (type, content, styling, position), unlike the styling-only overwrite above. + const id = `annotation-${nextAnnotationIdRef.current++}`; + const region = buildPastedAnnotation( + { + id, + startMs: startPos, + endMs: Math.min(startPos + defaultDuration, totalMs), + zIndex: nextAnnotationZIndexRef.current++, + }, + copied, + ); + pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, region] })); + handleSelectAnnotation(id); + pastedToast(); + }, [ + selectedZoomId, + selectedSpeedId, + selectedAnnotationId, + zoomRegions, + speedRegions, + duration, + currentTime, + pushState, + handleSelectZoom, + handleSelectSpeed, + handleSelectAnnotation, + t, + ]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const mod = e.ctrlKey || e.metaKey; @@ -1658,6 +1839,25 @@ export default function VideoEditor() { return; } + // Copy/paste region attributes. Skipped while typing in a field so native + // text copy/paste keeps working. + const editingText = + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + (e.target instanceof HTMLElement && e.target.isContentEditable); + if (!editingText) { + if (matchesShortcut(e, shortcuts.copySelected, isMac)) { + e.preventDefault(); + handleCopySelected(); + return; + } + if (matchesShortcut(e, shortcuts.paste, isMac)) { + e.preventDefault(); + handlePaste(); + return; + } + } + // Frame-step navigation (arrow keys, no modifiers) if ( (e.key === "ArrowLeft" || e.key === "ArrowRight") && @@ -1714,7 +1914,7 @@ export default function VideoEditor() { window.addEventListener("keydown", handleKeyDown, { capture: true }); return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }, [undo, redo, shortcuts, isMac]); + }, [undo, redo, shortcuts, isMac, handleCopySelected, handlePaste]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { diff --git a/src/components/video-editor/regionClipboard.test.ts b/src/components/video-editor/regionClipboard.test.ts new file mode 100644 index 000000000..5a3fce0d9 --- /dev/null +++ b/src/components/video-editor/regionClipboard.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { + applyAnnotationAttributes, + applySpeedAttributes, + applyZoomAttributes, + buildPastedAnnotation, + extractAnnotationAttributes, + extractSpeedAttributes, + extractZoomAttributes, +} from "./regionClipboard"; +import { + type AnnotationRegion, + DEFAULT_ANNOTATION_POSITION, + DEFAULT_ANNOTATION_SIZE, + DEFAULT_ANNOTATION_STYLE, + DEFAULT_FIGURE_DATA, + type SpeedRegion, + type ZoomRegion, +} from "./types"; + +const zoom: ZoomRegion = { + id: "zoom-1", + startMs: 1000, + endMs: 3000, + depth: 4, + customScale: 2.75, + focus: { cx: 0.2, cy: 0.8 }, + focusMode: "manual", + rotationPreset: "iso", + source: "manual", +}; + +const speed: SpeedRegion = { id: "speed-1", startMs: 0, endMs: 500, speed: 2 }; + +const annotation: AnnotationRegion = { + id: "annotation-1", + startMs: 0, + endMs: 2000, + type: "figure", + content: "hello", + position: { x: 10, y: 90 }, + size: { width: 40, height: 25 }, + style: { ...DEFAULT_ANNOTATION_STYLE, color: "#ff0000", textAnimation: "pop" }, + zIndex: 3, + figureData: { ...DEFAULT_FIGURE_DATA, color: "#123456" }, +}; + +describe("zoom attribute copy/paste", () => { + it("round-trips the copyable attributes onto a different clip while keeping its identity/timing", () => { + const attrs = extractZoomAttributes(zoom); + const target: ZoomRegion = { + id: "zoom-2", + startMs: 9000, + endMs: 9500, + depth: 1, + focus: { cx: 0.5, cy: 0.5 }, + source: "manual", + }; + const result = applyZoomAttributes(target, attrs); + + expect(result.id).toBe("zoom-2"); + expect(result.startMs).toBe(9000); + expect(result.endMs).toBe(9500); + expect(result.depth).toBe(4); + expect(result.customScale).toBe(2.75); + expect(result.focus).toEqual({ cx: 0.2, cy: 0.8 }); + expect(result.focusMode).toBe("manual"); + expect(result.rotationPreset).toBe("iso"); + }); + + it("deep-copies focus so the source and target are decoupled", () => { + const attrs = extractZoomAttributes(zoom); + const result = applyZoomAttributes({ ...zoom, id: "zoom-2" }, attrs); + result.focus.cx = 0.99; + expect(zoom.focus.cx).toBe(0.2); + }); +}); + +describe("speed attribute copy/paste", () => { + it("copies only the speed value", () => { + const attrs = extractSpeedAttributes(speed); + const target: SpeedRegion = { id: "speed-2", startMs: 4000, endMs: 5000, speed: 1 }; + const result = applySpeedAttributes(target, attrs); + expect(result).toEqual({ id: "speed-2", startMs: 4000, endMs: 5000, speed: 2 }); + }); +}); + +describe("annotation copy captures everything", () => { + it("captures styling plus content, type, and position", () => { + const attrs = extractAnnotationAttributes(annotation); + expect(attrs.type).toBe("figure"); + expect(attrs.content).toBe("hello"); + expect(attrs.position).toEqual({ x: 10, y: 90 }); + expect(attrs.style.color).toBe("#ff0000"); + expect(attrs.figureData?.color).toBe("#123456"); + }); +}); + +describe("paste onto an existing annotation applies styling only", () => { + it("overwrites the look/feel but keeps the target's content, position, timing, and zIndex", () => { + const attrs = extractAnnotationAttributes(annotation); + const target: AnnotationRegion = { + id: "annotation-2", + startMs: 7000, + endMs: 8000, + type: "text", + content: "world", + position: { ...DEFAULT_ANNOTATION_POSITION }, + size: { ...DEFAULT_ANNOTATION_SIZE }, + style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex: 9, + }; + const result = applyAnnotationAttributes(target, attrs); + + expect(result.content).toBe("world"); + expect(result.position).toEqual(DEFAULT_ANNOTATION_POSITION); + expect(result.startMs).toBe(7000); + expect(result.zIndex).toBe(9); + expect(result.style.color).toBe("#ff0000"); + expect(result.style.textAnimation).toBe("pop"); + expect(result.size).toEqual({ width: 40, height: 25 }); + expect(result.figureData?.color).toBe("#123456"); + }); + + it("keeps the target's own figure data when the copied region has none", () => { + const textAttrs = extractAnnotationAttributes({ ...annotation, figureData: undefined }); + const figureTarget: AnnotationRegion = { ...annotation, id: "annotation-3" }; + const result = applyAnnotationAttributes(figureTarget, textAttrs); + expect(result.figureData?.color).toBe("#123456"); + }); +}); + +describe("paste as a new annotation clones the full copy", () => { + it("clones type, content, styling, and position; takes timing/identity from the base", () => { + const attrs = extractAnnotationAttributes(annotation); + const result = buildPastedAnnotation( + { id: "annotation-4", startMs: 12000, endMs: 14000, zIndex: 5 }, + attrs, + ); + + expect(result.id).toBe("annotation-4"); + expect(result.startMs).toBe(12000); + expect(result.endMs).toBe(14000); + expect(result.zIndex).toBe(5); + expect(result.type).toBe("figure"); + expect(result.content).toBe("hello"); + expect(result.position).toEqual({ x: 10, y: 90 }); + expect(result.style.color).toBe("#ff0000"); + expect(result.figureData?.color).toBe("#123456"); + }); + + it("deep-copies position so source and clone are decoupled", () => { + const attrs = extractAnnotationAttributes(annotation); + const result = buildPastedAnnotation( + { id: "annotation-5", startMs: 0, endMs: 1000, zIndex: 1 }, + attrs, + ); + result.position.x = 99; + expect(annotation.position.x).toBe(10); + }); +}); diff --git a/src/components/video-editor/regionClipboard.ts b/src/components/video-editor/regionClipboard.ts new file mode 100644 index 000000000..fdacafaad --- /dev/null +++ b/src/components/video-editor/regionClipboard.ts @@ -0,0 +1,132 @@ +import type { + AnnotationPosition, + AnnotationRegion, + AnnotationSize, + AnnotationTextStyle, + AnnotationType, + FigureData, + PlaybackSpeed, + Rotation3DPreset, + SpeedRegion, + ZoomDepth, + ZoomFocus, + ZoomFocusMode, + ZoomRegion, +} from "./types"; + +/** Timeline Region kinds whose attributes can be copied. Trim has no attributes */ +export type RegionKind = "zoom" | "speed" | "annotation"; + +/** ZoomRegion attributes that can be copied */ +export interface ZoomAttributes { + kind: "zoom"; + depth: ZoomDepth; + customScale?: number; + focus: ZoomFocus; + focusMode?: ZoomFocusMode; + rotationPreset?: Rotation3DPreset; +} + +/** SpeedRegion attributes that can be copied */ +export interface SpeedAttributes { + kind: "speed"; + speed: PlaybackSpeed; +} + +/** AnnotationRegion attributes that can be copied. Copy captures everything; paste then + * uses only the styling for an existing region, or the full set for a brand-new one. */ +export interface AnnotationAttributes { + kind: "annotation"; + // Styling — applied both when pasting onto an existing region and onto a new one. + style: AnnotationTextStyle; + size: AnnotationSize; + figureData?: FigureData; + // Content & placement — used only when pasting as a brand-new region. + type: AnnotationType; + content: string; + textContent?: string; + imageContent?: string; + position: AnnotationPosition; +} + +export type CopiedRegion = ZoomAttributes | SpeedAttributes | AnnotationAttributes; + +export function extractZoomAttributes(region: ZoomRegion): ZoomAttributes { + return { + kind: "zoom", + depth: region.depth, + customScale: region.customScale, + focus: { ...region.focus }, + focusMode: region.focusMode, + rotationPreset: region.rotationPreset, + }; +} + +export function extractSpeedAttributes(region: SpeedRegion): SpeedAttributes { + return { kind: "speed", speed: region.speed }; +} + +export function extractAnnotationAttributes(region: AnnotationRegion): AnnotationAttributes { + return { + kind: "annotation", + style: { ...region.style }, + size: { ...region.size }, + figureData: region.figureData ? { ...region.figureData } : undefined, + type: region.type, + content: region.content, + textContent: region.textContent, + imageContent: region.imageContent, + position: { ...region.position }, + }; +} + +/** Returns a new region with the copied attributes overwriting its own. Identity and + * timing (id, startMs, endMs) are preserved; nested objects are deep-copied. */ +export function applyZoomAttributes(region: ZoomRegion, attrs: ZoomAttributes): ZoomRegion { + return { + ...region, + depth: attrs.depth, + customScale: attrs.customScale, + focus: { ...attrs.focus }, + focusMode: attrs.focusMode, + rotationPreset: attrs.rotationPreset, + }; +} + +export function applySpeedAttributes(region: SpeedRegion, attrs: SpeedAttributes): SpeedRegion { + return { ...region, speed: attrs.speed }; +} + +/** Pastes onto an EXISTING annotation: only the styling is overwritten — the target keeps + * its own type, text/image content, position, timing, and stacking order. */ +export function applyAnnotationAttributes( + region: AnnotationRegion, + attrs: AnnotationAttributes, +): AnnotationRegion { + return { + ...region, + style: { ...attrs.style }, + size: { ...attrs.size }, + // Keep the target's own figure data when the copied region has none (e.g. text → figure). + figureData: attrs.figureData ? { ...attrs.figureData } : region.figureData, + }; +} + +/** Builds a BRAND-NEW annotation from a full copy: clones type, content, styling, size, + * figure data, and position. Identity, timing, and stacking order come from `base`. */ +export function buildPastedAnnotation( + base: Pick, + attrs: AnnotationAttributes, +): AnnotationRegion { + return { + ...base, + type: attrs.type, + content: attrs.content, + textContent: attrs.textContent, + imageContent: attrs.imageContent, + position: { ...attrs.position }, + size: { ...attrs.size }, + style: { ...attrs.style }, + figureData: attrs.figureData ? { ...attrs.figureData } : undefined, + }; +} diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 39750e5eb..3ba6a4eea 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "تعذّر فتح الملف", "couldNotOpenMessage": "تعذّر فتح ملف المشروع. ربما تم نقل الفيديو المرجعي أو حذفه." } + }, + "regionClipboard": { + "copied": "تم نسخ سمات {{region}}", + "pasted": "تم لصق سمات {{region}}", + "nothingToCopy": "حدد منطقة لنسخ سماتها", + "nothingToPaste": "لم يتم نسخ أي سمات بعد", + "cannotPlace": "لا يمكن وضع المنطقة هنا: ستتداخل مع منطقة أخرى", + "kinds": { + "zoom": "تكبير", + "speed": "سرعة", + "annotation": "نص" + } } } diff --git a/src/i18n/locales/ar/shortcuts.json b/src/i18n/locales/ar/shortcuts.json index b18e3195e..44c2f22fd 100644 --- a/src/i18n/locales/ar/shortcuts.json +++ b/src/i18n/locales/ar/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "إضافة تمويه", "addKeyframe": "إضافة إطار رئيسي", "deleteSelected": "حذف المحدد", - "playPause": "تشغيل / إيقاف مؤقت" + "playPause": "تشغيل / إيقاف مؤقت", + "copySelected": "نسخ المحدد", + "paste": "لصق" }, "fixedActions": { "undo": "تراجع", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index d6a56f033..f3d9c1bc6 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "Could Not Open File", "couldNotOpenMessage": "The project file could not be opened. The video it references may have been moved or deleted." } + }, + "regionClipboard": { + "copied": "{{region}} attributes copied", + "pasted": "{{region}} attributes pasted", + "nothingToCopy": "Select a region to copy its attributes", + "nothingToPaste": "No attributes copied yet", + "cannotPlace": "Can't place the region here — it would overlap another", + "kinds": { + "zoom": "Zoom", + "speed": "Speed", + "annotation": "Text" + } } } diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json index 8994df130..d5a4b0dd8 100644 --- a/src/i18n/locales/en/shortcuts.json +++ b/src/i18n/locales/en/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Add Blur", "addKeyframe": "Add Keyframe", "deleteSelected": "Delete Selected", - "playPause": "Play / Pause" + "playPause": "Play / Pause", + "copySelected": "Copy Selected", + "paste": "Paste" }, "fixedActions": { "undo": "Undo", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 277ce40ff..bc12b8fd7 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "No se pudo abrir el archivo", "couldNotOpenMessage": "No se pudo abrir el archivo de proyecto. El video al que hace referencia puede haber sido movido o eliminado." } + }, + "regionClipboard": { + "copied": "Atributos de {{region}} copiados", + "pasted": "Atributos de {{region}} pegados", + "nothingToCopy": "Selecciona una región para copiar sus atributos", + "nothingToPaste": "Aún no se han copiado atributos", + "cannotPlace": "No se puede colocar la región aquí: se superpondría con otra", + "kinds": { + "zoom": "Zoom", + "speed": "Velocidad", + "annotation": "Texto" + } } } diff --git a/src/i18n/locales/es/shortcuts.json b/src/i18n/locales/es/shortcuts.json index 49767d560..b23a1cbe0 100644 --- a/src/i18n/locales/es/shortcuts.json +++ b/src/i18n/locales/es/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Agregar desenfoque", "addKeyframe": "Agregar fotograma clave", "deleteSelected": "Eliminar seleccionado", - "playPause": "Reproducir / Pausar" + "playPause": "Reproducir / Pausar", + "copySelected": "Copiar selección", + "paste": "Pegar" }, "fixedActions": { "undo": "Deshacer", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 40dc24fd7..d3cc39523 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "Impossible d'ouvrir le fichier", "couldNotOpenMessage": "Le fichier de projet n'a pas pu être ouvert. La vidéo qu'il référence a peut-être été déplacée ou supprimée." } + }, + "regionClipboard": { + "copied": "Attributs de {{region}} copiés", + "pasted": "Attributs de {{region}} collés", + "nothingToCopy": "Sélectionnez une région pour copier ses attributs", + "nothingToPaste": "Aucun attribut copié pour l'instant", + "cannotPlace": "Impossible de placer la région ici : elle chevaucherait une autre", + "kinds": { + "zoom": "Zoom", + "speed": "Vitesse", + "annotation": "Texte" + } } } diff --git a/src/i18n/locales/fr/shortcuts.json b/src/i18n/locales/fr/shortcuts.json index eec8a5914..56451fcca 100644 --- a/src/i18n/locales/fr/shortcuts.json +++ b/src/i18n/locales/fr/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Ajouter un flou", "addKeyframe": "Ajouter une image-clé", "deleteSelected": "Supprimer la sélection", - "playPause": "Lecture / Pause" + "playPause": "Lecture / Pause", + "copySelected": "Copier la sélection", + "paste": "Coller" }, "fixedActions": { "undo": "Annuler", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 0e94b9a9f..54edc4787 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -61,5 +61,17 @@ "noAudio": "Questo video non contiene audio utilizzabile per la trascrizione.", "failed": "Impossibile generare i sottotitoli.", "truncated": "Sono stati trascritti solo i primi {{minutes}} minuti." + }, + "regionClipboard": { + "copied": "Attributi di {{region}} copiati", + "pasted": "Attributi di {{region}} incollati", + "nothingToCopy": "Seleziona una regione per copiarne gli attributi", + "nothingToPaste": "Nessun attributo copiato", + "cannotPlace": "Impossibile posizionare la regione qui: si sovrapporrebbe a un'altra", + "kinds": { + "zoom": "Zoom", + "speed": "Velocità", + "annotation": "Testo" + } } } diff --git a/src/i18n/locales/it/shortcuts.json b/src/i18n/locales/it/shortcuts.json index 051a88871..528d149bc 100644 --- a/src/i18n/locales/it/shortcuts.json +++ b/src/i18n/locales/it/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Aggiungi sfocatura", "addKeyframe": "Aggiungi fotogramma chiave", "deleteSelected": "Elimina selezionato", - "playPause": "Riproduci / Pausa" + "playPause": "Riproduci / Pausa", + "copySelected": "Copia selezione", + "paste": "Incolla" }, "fixedActions": { "undo": "Annulla", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 8e0da42e1..0c416c661 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "ファイルを開けませんでした", "couldNotOpenMessage": "プロジェクトファイルを開けませんでした。参照している動画が移動または削除された可能性があります。" } + }, + "regionClipboard": { + "copied": "{{region}}の属性をコピーしました", + "pasted": "{{region}}の属性を貼り付けました", + "nothingToCopy": "属性をコピーする領域を選択してください", + "nothingToPaste": "コピーされた属性がありません", + "cannotPlace": "ここには領域を配置できません(他の領域と重なります)", + "kinds": { + "zoom": "ズーム", + "speed": "速度", + "annotation": "テキスト" + } } } diff --git a/src/i18n/locales/ja-JP/shortcuts.json b/src/i18n/locales/ja-JP/shortcuts.json index 1d574198e..fc5d6de32 100644 --- a/src/i18n/locales/ja-JP/shortcuts.json +++ b/src/i18n/locales/ja-JP/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "ぼかしを追加", "addKeyframe": "キーフレームを追加", "deleteSelected": "選択を削除", - "playPause": "再生 / 一時停止" + "playPause": "再生 / 一時停止", + "copySelected": "選択をコピー", + "paste": "貼り付け" }, "fixedActions": { "undo": "元に戻す", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index a63a22a57..2d432941e 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "파일을 열 수 없음", "couldNotOpenMessage": "프로젝트 파일을 열 수 없습니다. 참조된 동영상이 이동되었거나 삭제되었을 수 있습니다." } + }, + "regionClipboard": { + "copied": "{{region}} 속성을 복사했습니다", + "pasted": "{{region}} 속성을 붙여넣었습니다", + "nothingToCopy": "속성을 복사할 영역을 선택하세요", + "nothingToPaste": "복사된 속성이 없습니다", + "cannotPlace": "여기에는 영역을 배치할 수 없습니다. 다른 영역과 겹칩니다", + "kinds": { + "zoom": "줌", + "speed": "속도", + "annotation": "텍스트" + } } } diff --git a/src/i18n/locales/ko-KR/shortcuts.json b/src/i18n/locales/ko-KR/shortcuts.json index ddac29542..53a2de968 100644 --- a/src/i18n/locales/ko-KR/shortcuts.json +++ b/src/i18n/locales/ko-KR/shortcuts.json @@ -23,7 +23,9 @@ "addKeyframe": "키프레임 추가", "deleteSelected": "선택 항목 삭제", "playPause": "재생 / 일시정지", - "addBlur": "블러 추가" + "addBlur": "블러 추가", + "copySelected": "선택 항목 복사", + "paste": "붙여넣기" }, "fixedActions": { "undo": "실행 취소", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index b0e9ab8c9..c78545713 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -60,5 +60,17 @@ "noAudio": "Este vídeo não tem áudio utilizável para transcrição.", "failed": "Não foi possível gerar as legendas.", "truncated": "Apenas os primeiros {{minutes}} minutos foram transcritos." + }, + "regionClipboard": { + "copied": "Atributos de {{region}} copiados", + "pasted": "Atributos de {{region}} colados", + "nothingToCopy": "Selecione uma região para copiar seus atributos", + "nothingToPaste": "Nenhum atributo copiado ainda", + "cannotPlace": "Não é possível colocar a região aqui: ela se sobreporia a outra", + "kinds": { + "zoom": "Zoom", + "speed": "Velocidade", + "annotation": "Texto" + } } } diff --git a/src/i18n/locales/pt-BR/shortcuts.json b/src/i18n/locales/pt-BR/shortcuts.json index 208cd1dc8..0187ed554 100644 --- a/src/i18n/locales/pt-BR/shortcuts.json +++ b/src/i18n/locales/pt-BR/shortcuts.json @@ -21,7 +21,9 @@ "addBlur": "Adicionar Desfoque", "addKeyframe": "Adicionar Quadro-chave", "deleteSelected": "Excluir Selecionado", - "playPause": "Reproduzir / Pausar" + "playPause": "Reproduzir / Pausar", + "copySelected": "Copiar seleção", + "paste": "Colar" }, "fixedActions": { "undo": "Desfazer", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 78fa129a1..c654c14dd 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "Не удалось открыть файл", "couldNotOpenMessage": "Не удалось открыть файл проекта. Видео, на которое он ссылается, возможно, было перемещено или удалено." } + }, + "regionClipboard": { + "copied": "Атрибуты «{{region}}» скопированы", + "pasted": "Атрибуты «{{region}}» вставлены", + "nothingToCopy": "Выберите регион, чтобы скопировать его атрибуты", + "nothingToPaste": "Атрибуты ещё не скопированы", + "cannotPlace": "Не удаётся разместить регион здесь: он перекрыл бы другой", + "kinds": { + "zoom": "Масштаб", + "speed": "Скорость", + "annotation": "Текст" + } } } diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json index b6e1faa5c..47ae35f24 100644 --- a/src/i18n/locales/ru/shortcuts.json +++ b/src/i18n/locales/ru/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Добавить размытие", "addKeyframe": "Добавить ключевой кадр", "deleteSelected": "Удалить выбранное", - "playPause": "Воспроизведение / Пауза" + "playPause": "Воспроизведение / Пауза", + "copySelected": "Копировать выбранное", + "paste": "Вставить" }, "fixedActions": { "undo": "Отменить", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 89203e719..7e69e891a 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "Dosya Açılamadı", "couldNotOpenMessage": "Proje dosyası açılamadı. Başvurulan video taşınmış veya silinmiş olabilir." } + }, + "regionClipboard": { + "copied": "{{region}} öznitelikleri kopyalandı", + "pasted": "{{region}} öznitelikleri yapıştırıldı", + "nothingToCopy": "Özniteliklerini kopyalamak için bir bölge seçin", + "nothingToPaste": "Henüz öznitelik kopyalanmadı", + "cannotPlace": "Bölge buraya yerleştirilemiyor: başka bir bölgeyle çakışır", + "kinds": { + "zoom": "Yakınlaştırma", + "speed": "Hız", + "annotation": "Metin" + } } } diff --git a/src/i18n/locales/tr/shortcuts.json b/src/i18n/locales/tr/shortcuts.json index 62cdfaf5f..24c1ea502 100644 --- a/src/i18n/locales/tr/shortcuts.json +++ b/src/i18n/locales/tr/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Bulanik Ekle", "addKeyframe": "Anahtar Kare Ekle", "deleteSelected": "Seçileni Sil", - "playPause": "Oynat / Duraklat" + "playPause": "Oynat / Duraklat", + "copySelected": "Seçileni Kopyala", + "paste": "Yapıştır" }, "fixedActions": { "undo": "Geri Al", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 90004091e..f754233dc 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "Không thể mở tệp", "couldNotOpenMessage": "Không thể mở tệp dự án. Video mà nó tham chiếu có thể đã bị di chuyển hoặc xóa." } + }, + "regionClipboard": { + "copied": "Đã sao chép thuộc tính {{region}}", + "pasted": "Đã dán thuộc tính {{region}}", + "nothingToCopy": "Chọn một vùng để sao chép thuộc tính của nó", + "nothingToPaste": "Chưa sao chép thuộc tính nào", + "cannotPlace": "Không thể đặt vùng ở đây: nó sẽ chồng lên vùng khác", + "kinds": { + "zoom": "Thu phóng", + "speed": "Tốc độ", + "annotation": "Văn bản" + } } } diff --git a/src/i18n/locales/vi/shortcuts.json b/src/i18n/locales/vi/shortcuts.json index 46ec9b5e5..a1226b0ef 100644 --- a/src/i18n/locales/vi/shortcuts.json +++ b/src/i18n/locales/vi/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "Thêm Làm mờ", "addKeyframe": "Thêm Khung hình chính", "deleteSelected": "Xóa mục đã chọn", - "playPause": "Phát / Tạm dừng" + "playPause": "Phát / Tạm dừng", + "copySelected": "Sao chép mục đã chọn", + "paste": "Dán" }, "fixedActions": { "undo": "Hoàn tác", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 58f6ae27b..fae1ff2aa 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "无法打开文件", "couldNotOpenMessage": "无法打开项目文件。它引用的视频可能已被移动或删除。" } + }, + "regionClipboard": { + "copied": "已复制{{region}}属性", + "pasted": "已粘贴{{region}}属性", + "nothingToCopy": "选择一个区域以复制其属性", + "nothingToPaste": "尚未复制任何属性", + "cannotPlace": "无法在此放置区域:会与其他区域重叠", + "kinds": { + "zoom": "缩放", + "speed": "速度", + "annotation": "文本" + } } } diff --git a/src/i18n/locales/zh-CN/shortcuts.json b/src/i18n/locales/zh-CN/shortcuts.json index eb357e0e3..b3204cb2b 100644 --- a/src/i18n/locales/zh-CN/shortcuts.json +++ b/src/i18n/locales/zh-CN/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "添加模糊", "addKeyframe": "添加关键帧", "deleteSelected": "删除所选", - "playPause": "播放 / 暂停" + "playPause": "播放 / 暂停", + "copySelected": "复制所选", + "paste": "粘贴" }, "fixedActions": { "undo": "撤销", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 8a6485409..9038f7e3a 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -77,5 +77,17 @@ "couldNotOpenTitle": "無法開啟檔案", "couldNotOpenMessage": "無法開啟專案檔案。它所參照的影片可能已被移動或刪除。" } + }, + "regionClipboard": { + "copied": "已複製{{region}}屬性", + "pasted": "已貼上{{region}}屬性", + "nothingToCopy": "選擇一個區域以複製其屬性", + "nothingToPaste": "尚未複製任何屬性", + "cannotPlace": "無法在此放置區域:會與其他區域重疊", + "kinds": { + "zoom": "縮放", + "speed": "速度", + "annotation": "文字" + } } } diff --git a/src/i18n/locales/zh-TW/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json index 34ab7de81..65f148478 100644 --- a/src/i18n/locales/zh-TW/shortcuts.json +++ b/src/i18n/locales/zh-TW/shortcuts.json @@ -23,7 +23,9 @@ "addBlur": "新增模糊", "addKeyframe": "新增關鍵影格", "deleteSelected": "刪除所選", - "playPause": "播放 / 暫停" + "playPause": "播放 / 暫停", + "copySelected": "複製所選", + "paste": "貼上" }, "fixedActions": { "undo": "復原", diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 485fe89df..16129ef4c 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -8,6 +8,8 @@ export const SHORTCUT_ACTIONS = [ "addKeyframe", "deleteSelected", "playPause", + "copySelected", + "paste", ] as const; export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number]; @@ -115,6 +117,8 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { addKeyframe: { key: "f" }, deleteSelected: { key: "d", ctrl: true }, playPause: { key: " " }, + copySelected: { key: "c", ctrl: true }, + paste: { key: "v", ctrl: true }, }; export const SHORTCUT_LABELS: Record = { @@ -127,6 +131,8 @@ export const SHORTCUT_LABELS: Record = { addKeyframe: "Add Keyframe", deleteSelected: "Delete Selected", playPause: "Play / Pause", + copySelected: "Copy Selected", + paste: "Paste", }; export function matchesShortcut( From e0f6f65dcf60325e00f74800ddca5134501f4c4f Mon Sep 17 00:00:00 2001 From: 446f6e6e79 <66618414+446f6e6e79@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:17:58 +0200 Subject: [PATCH 2/4] 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. --- src/components/video-editor/VideoEditor.tsx | 158 ++++++++++-------- .../video-editor/regionClipboard.test.ts | 60 ++++++- .../video-editor/regionClipboard.ts | 82 +++++---- .../video-editor/regionPlacement.test.ts | 48 ++++++ .../video-editor/regionPlacement.ts | 24 +++ .../video-editor/timeline/TimelineEditor.tsx | 41 ++--- src/i18n/locales/ar/editor.json | 1 - src/i18n/locales/en/editor.json | 1 - src/i18n/locales/es/editor.json | 1 - src/i18n/locales/fr/editor.json | 1 - src/i18n/locales/it/editor.json | 1 - src/i18n/locales/ja-JP/editor.json | 1 - src/i18n/locales/ko-KR/editor.json | 1 - src/i18n/locales/pt-BR/editor.json | 1 - src/i18n/locales/ru/editor.json | 1 - src/i18n/locales/tr/editor.json | 1 - src/i18n/locales/vi/editor.json | 1 - src/i18n/locales/zh-CN/editor.json | 1 - src/i18n/locales/zh-TW/editor.json | 1 - src/lib/shortcuts.ts | 9 + 20 files changed, 280 insertions(+), 155 deletions(-) create mode 100644 src/components/video-editor/regionPlacement.test.ts create mode 100644 src/components/video-editor/regionPlacement.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 5f9ec0f26..1ded512bb 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -51,7 +51,7 @@ import { } from "@/lib/exporter"; import { computeFrameStepTime } from "@/lib/frameStep"; import type { CursorCaptureMode, ProjectMedia } from "@/lib/recordingSession"; -import { matchesShortcut } from "@/lib/shortcuts"; +import { isTextEditingTarget, matchesShortcut } from "@/lib/shortcuts"; import { getExportFolder, getProjectFolder, @@ -88,15 +88,18 @@ import { validateProjectData, } from "./projectPersistence"; import { - applyAnnotationAttributes, - applySpeedAttributes, - applyZoomAttributes, buildPastedAnnotation, + buildSpeedRegion, + buildZoomRegion, type CopiedRegion, extractAnnotationAttributes, extractSpeedAttributes, extractZoomAttributes, + getCopiedRegion, + replaceAnnotationAttributes, + setCopiedRegion, } from "./regionClipboard"; +import { findFreeGapAt } from "./regionPlacement"; import { SettingsPanel } from "./SettingsPanel"; import TimelineEditor from "./timeline/TimelineEditor"; import { buildAutoZoomSuggestions } from "./timeline/zoomSuggestionUtils"; @@ -310,9 +313,6 @@ export default function VideoEditor() { const nextTrimIdRef = useRef(1); const nextSpeedIdRef = useRef(1); - // Session clipboard for "copy/paste region attributes" (not undoable, not persisted). - const regionClipboardRef = useRef(null); - const { shortcuts, isMac } = useShortcuts(); // Windows recordings include captured cursor assets. macOS hides the system // cursor in ScreenCaptureKit and renders telemetry samples with OpenScreen's @@ -326,6 +326,7 @@ export default function VideoEditor() { const { locale, setLocale, t: rawT } = useI18n(); const t = useScopedT("editor"); const ts = useScopedT("settings"); + const tt = useScopedT("timeline"); const availableLocales = getAvailableLocales(); const nextAnnotationIdRef = useRef(1); @@ -1654,29 +1655,28 @@ export default function VideoEditor() { ); const handleCopySelected = useCallback(() => { - if (selectedZoomId) { - const region = zoomRegions.find((r) => r.id === selectedZoomId); - if (region) { - regionClipboardRef.current = extractZoomAttributes(region); - toast.success(t("regionClipboard.copied", { region: t("regionClipboard.kinds.zoom") })); - } - return; - } - if (selectedSpeedId) { - const region = speedRegions.find((r) => r.id === selectedSpeedId); - if (region) { - regionClipboardRef.current = extractSpeedAttributes(region); - toast.success(t("regionClipboard.copied", { region: t("regionClipboard.kinds.speed") })); - } - return; - } - if (selectedAnnotationId) { - const region = annotationRegions.find((r) => r.id === selectedAnnotationId); + // Copy the selected region of any kind into the clipboard. A selected blur is an + // annotation (type "blur" lives in annotationRegions), so it copies via that row. + const copyTargets = [ + [selectedZoomId, zoomRegions, extractZoomAttributes, "zoom"], + [selectedSpeedId, speedRegions, extractSpeedAttributes, "speed"], + [ + selectedAnnotationId ?? selectedBlurId, + annotationRegions, + extractAnnotationAttributes, + "annotation", + ], + ] as const; + + for (const [id, regions, extract, kind] of copyTargets) { + if (!id) continue; + const region = (regions as readonly { id: string }[]).find((r) => r.id === id); if (region) { - regionClipboardRef.current = extractAnnotationAttributes(region); - toast.success( - t("regionClipboard.copied", { region: t("regionClipboard.kinds.annotation") }), - ); + // Each row pairs a region list with its matching extractor, so the cast is sound. + setCopiedRegion((extract as (r: never) => CopiedRegion)(region as never)); + toast.success(t("regionClipboard.copied", { region: t(`regionClipboard.kinds.${kind}`) }), { + id: "regionClipboard.copied", + }); } return; } @@ -1685,6 +1685,7 @@ export default function VideoEditor() { selectedZoomId, selectedSpeedId, selectedAnnotationId, + selectedBlurId, zoomRegions, speedRegions, annotationRegions, @@ -1692,42 +1693,56 @@ export default function VideoEditor() { ]); const handlePaste = useCallback(() => { - const copied = regionClipboardRef.current; + const copied = getCopiedRegion(); // If there's nothing in the clipboard, show a message and return early. if (!copied) { toast.info(t("regionClipboard.nothingToPaste")); return; } - const regionLabel = t(`regionClipboard.kinds.${copied.kind}`); - const pastedToast = () => toast.success(t("regionClipboard.pasted", { region: regionLabel })); - // Apply onto the selected region of the same kind, keeping its timing. if (copied.kind === "zoom" && selectedZoomId) { pushState((prev) => ({ zoomRegions: prev.zoomRegions.map((r) => - r.id === selectedZoomId ? applyZoomAttributes(r, copied) : r, + r.id === selectedZoomId ? buildZoomRegion(r, copied) : r, ), })); - pastedToast(); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); return; } if (copied.kind === "speed" && selectedSpeedId) { pushState((prev) => ({ speedRegions: prev.speedRegions.map((r) => - r.id === selectedSpeedId ? applySpeedAttributes(r, copied) : r, + r.id === selectedSpeedId ? buildSpeedRegion(r, copied) : r, ), })); - pastedToast(); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); return; } - if (copied.kind === "annotation" && selectedAnnotationId) { + // Blurs live in annotationRegions (type "blur"), so a selected blur is a valid target too. + if (copied.kind === "annotation" && (selectedAnnotationId || selectedBlurId)) { + const targetId = selectedAnnotationId ?? selectedBlurId; pushState((prev) => ({ annotationRegions: prev.annotationRegions.map((r) => - r.id === selectedAnnotationId ? applyAnnotationAttributes(r, copied) : r, + r.id === targetId ? replaceAnnotationAttributes(r, copied) : r, ), })); - pastedToast(); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); return; } @@ -1738,56 +1753,59 @@ export default function VideoEditor() { const startPos = Math.max(0, Math.min(Math.round(currentTime * 1000), totalMs)); if (copied.kind === "zoom") { - const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((r) => r.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - const overlapping = sorted.some((r) => startPos >= r.startMs && startPos < r.endMs); - - if (overlapping || gapToNext <= 0) { - toast.error(t("regionClipboard.cannotPlace")); + const { ok, gapMs } = findFreeGapAt(zoomRegions, startPos, totalMs); + if (!ok) { + toast.error(tt("errors.cannotPlaceZoom"), { + description: tt("errors.zoomExistsAtLocation"), + }); return; } const id = `zoom-${nextZoomIdRef.current++}`; - const region = applyZoomAttributes( + const region = buildZoomRegion( { id, startMs: startPos, - endMs: startPos + Math.min(defaultDuration, gapToNext), - depth: DEFAULT_ZOOM_DEPTH, - focus: { cx: 0.5, cy: 0.5 }, + endMs: startPos + Math.min(defaultDuration, gapMs), source: "manual", }, copied, ); pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, region] })); handleSelectZoom(id); - pastedToast(); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); return; } if (copied.kind === "speed") { - const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((r) => r.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - const overlapping = sorted.some((r) => startPos >= r.startMs && startPos < r.endMs); - - if (overlapping || gapToNext <= 0) { - toast.error(t("regionClipboard.cannotPlace")); + const { ok, gapMs } = findFreeGapAt(speedRegions, startPos, totalMs); + if (!ok) { + toast.error(tt("errors.cannotPlaceSpeed"), { + description: tt("errors.speedExistsAtLocation"), + }); return; } const id = `speed-${nextSpeedIdRef.current++}`; - const region = applySpeedAttributes( + const region = buildSpeedRegion( { id, startMs: startPos, - endMs: startPos + Math.min(defaultDuration, gapToNext), - speed: DEFAULT_PLAYBACK_SPEED, + endMs: startPos + Math.min(defaultDuration, gapMs), }, copied, ); pushState((prev) => ({ speedRegions: [...prev.speedRegions, region] })); handleSelectSpeed(id); - pastedToast(); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); return; } @@ -1805,11 +1823,17 @@ export default function VideoEditor() { ); pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, region] })); handleSelectAnnotation(id); - pastedToast(); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); }, [ selectedZoomId, selectedSpeedId, selectedAnnotationId, + selectedBlurId, zoomRegions, speedRegions, duration, @@ -1819,6 +1843,7 @@ export default function VideoEditor() { handleSelectSpeed, handleSelectAnnotation, t, + tt, ]); useEffect(() => { @@ -1841,10 +1866,7 @@ export default function VideoEditor() { // Copy/paste region attributes. Skipped while typing in a field so native // text copy/paste keeps working. - const editingText = - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - (e.target instanceof HTMLElement && e.target.isContentEditable); + const editingText = isTextEditingTarget(e.target); if (!editingText) { if (matchesShortcut(e, shortcuts.copySelected, isMac)) { e.preventDefault(); diff --git a/src/components/video-editor/regionClipboard.test.ts b/src/components/video-editor/regionClipboard.test.ts index 5a3fce0d9..f99f17eb2 100644 --- a/src/components/video-editor/regionClipboard.test.ts +++ b/src/components/video-editor/regionClipboard.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { - applyAnnotationAttributes, - applySpeedAttributes, - applyZoomAttributes, buildPastedAnnotation, + buildSpeedRegion, + buildZoomRegion, extractAnnotationAttributes, extractSpeedAttributes, extractZoomAttributes, + replaceAnnotationAttributes, } from "./regionClipboard"; import { type AnnotationRegion, @@ -56,7 +56,7 @@ describe("zoom attribute copy/paste", () => { focus: { cx: 0.5, cy: 0.5 }, source: "manual", }; - const result = applyZoomAttributes(target, attrs); + const result = buildZoomRegion(target, attrs); expect(result.id).toBe("zoom-2"); expect(result.startMs).toBe(9000); @@ -70,7 +70,7 @@ describe("zoom attribute copy/paste", () => { it("deep-copies focus so the source and target are decoupled", () => { const attrs = extractZoomAttributes(zoom); - const result = applyZoomAttributes({ ...zoom, id: "zoom-2" }, attrs); + const result = buildZoomRegion({ ...zoom, id: "zoom-2" }, attrs); result.focus.cx = 0.99; expect(zoom.focus.cx).toBe(0.2); }); @@ -80,7 +80,7 @@ describe("speed attribute copy/paste", () => { it("copies only the speed value", () => { const attrs = extractSpeedAttributes(speed); const target: SpeedRegion = { id: "speed-2", startMs: 4000, endMs: 5000, speed: 1 }; - const result = applySpeedAttributes(target, attrs); + const result = buildSpeedRegion(target, attrs); expect(result).toEqual({ id: "speed-2", startMs: 4000, endMs: 5000, speed: 2 }); }); }); @@ -110,7 +110,7 @@ describe("paste onto an existing annotation applies styling only", () => { style: { ...DEFAULT_ANNOTATION_STYLE }, zIndex: 9, }; - const result = applyAnnotationAttributes(target, attrs); + const result = replaceAnnotationAttributes(target, attrs); expect(result.content).toBe("world"); expect(result.position).toEqual(DEFAULT_ANNOTATION_POSITION); @@ -119,13 +119,14 @@ describe("paste onto an existing annotation applies styling only", () => { expect(result.style.color).toBe("#ff0000"); expect(result.style.textAnimation).toBe("pop"); expect(result.size).toEqual({ width: 40, height: 25 }); - expect(result.figureData?.color).toBe("#123456"); + // Target is text, so the copied figure's figureData must NOT attach (F5 guard). + expect(result.figureData).toBeUndefined(); }); it("keeps the target's own figure data when the copied region has none", () => { const textAttrs = extractAnnotationAttributes({ ...annotation, figureData: undefined }); const figureTarget: AnnotationRegion = { ...annotation, id: "annotation-3" }; - const result = applyAnnotationAttributes(figureTarget, textAttrs); + const result = replaceAnnotationAttributes(figureTarget, textAttrs); expect(result.figureData?.color).toBe("#123456"); }); }); @@ -159,3 +160,44 @@ describe("paste as a new annotation clones the full copy", () => { expect(annotation.position.x).toBe(10); }); }); + +describe("zoom paste replaces customScale destructively (F4)", () => { + it("clears the target's customScale when the copied zoom is preset-only", () => { + const presetOnly = extractZoomAttributes({ ...zoom, customScale: undefined }); + const target: ZoomRegion = { ...zoom, id: "zoom-3", customScale: 1.5 }; + const result = buildZoomRegion(target, presetOnly); + expect(result.customScale).toBeUndefined(); + }); +}); + +describe("figureData guard on paste-onto-existing (F5)", () => { + it("does not attach figureData onto a non-figure target", () => { + const figureAttrs = extractAnnotationAttributes(annotation); + const textTarget: AnnotationRegion = { + id: "annotation-6", + startMs: 0, + endMs: 1000, + type: "text", + content: "hi", + position: { ...DEFAULT_ANNOTATION_POSITION }, + size: { ...DEFAULT_ANNOTATION_SIZE }, + style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex: 1, + }; + const result = replaceAnnotationAttributes(textTarget, figureAttrs); + expect(result.figureData).toBeUndefined(); + // Styling still applies regardless of type. + expect(result.style.color).toBe("#ff0000"); + }); + + it("applies figureData when the target is itself a figure", () => { + const figureAttrs = extractAnnotationAttributes(annotation); + const figureTarget: AnnotationRegion = { + ...annotation, + id: "annotation-7", + figureData: { ...DEFAULT_FIGURE_DATA, color: "#000000" }, + }; + const result = replaceAnnotationAttributes(figureTarget, figureAttrs); + expect(result.figureData?.color).toBe("#123456"); + }); +}); diff --git a/src/components/video-editor/regionClipboard.ts b/src/components/video-editor/regionClipboard.ts index fdacafaad..b1dd06c8c 100644 --- a/src/components/video-editor/regionClipboard.ts +++ b/src/components/video-editor/regionClipboard.ts @@ -14,28 +14,22 @@ import type { ZoomRegion, } from "./types"; -/** Timeline Region kinds whose attributes can be copied. Trim has no attributes */ -export type RegionKind = "zoom" | "speed" | "annotation"; - -/** ZoomRegion attributes that can be copied */ -export interface ZoomAttributes { +/** The copyable attributes of each region, tagged with its `kind` so paste can discriminate. + * Trim has no attributes, so it isn't copyable. */ +export type CopiedZoom = { kind: "zoom"; depth: ZoomDepth; customScale?: number; focus: ZoomFocus; focusMode?: ZoomFocusMode; rotationPreset?: Rotation3DPreset; -} +}; -/** SpeedRegion attributes that can be copied */ -export interface SpeedAttributes { - kind: "speed"; - speed: PlaybackSpeed; -} +export type CopiedSpeed = { kind: "speed"; speed: PlaybackSpeed }; -/** AnnotationRegion attributes that can be copied. Copy captures everything; paste then - * uses only the styling for an existing region, or the full set for a brand-new one. */ -export interface AnnotationAttributes { +/** Annotation copy captures everything; paste then uses only the styling for an existing + * region, or the full set for a brand-new one. */ +export type CopiedAnnotation = { kind: "annotation"; // Styling — applied both when pasting onto an existing region and onto a new one. style: AnnotationTextStyle; @@ -47,11 +41,23 @@ export interface AnnotationAttributes { textContent?: string; imageContent?: string; position: AnnotationPosition; +}; + +export type CopiedRegion = CopiedZoom | CopiedSpeed | CopiedAnnotation; + +/** Session clipboard for "copy/paste region attributes" (not undoable, not persisted). + * Module-level so it's shared regardless of which editor instance copied. */ +let clipboard: CopiedRegion | null = null; + +export function getCopiedRegion(): CopiedRegion | null { + return clipboard; } -export type CopiedRegion = ZoomAttributes | SpeedAttributes | AnnotationAttributes; +export function setCopiedRegion(region: CopiedRegion): void { + clipboard = region; +} -export function extractZoomAttributes(region: ZoomRegion): ZoomAttributes { +export function extractZoomAttributes(region: ZoomRegion): CopiedZoom { return { kind: "zoom", depth: region.depth, @@ -62,11 +68,11 @@ export function extractZoomAttributes(region: ZoomRegion): ZoomAttributes { }; } -export function extractSpeedAttributes(region: SpeedRegion): SpeedAttributes { +export function extractSpeedAttributes(region: SpeedRegion): CopiedSpeed { return { kind: "speed", speed: region.speed }; } -export function extractAnnotationAttributes(region: AnnotationRegion): AnnotationAttributes { +export function extractAnnotationAttributes(region: AnnotationRegion): CopiedAnnotation { return { kind: "annotation", style: { ...region.style }, @@ -80,35 +86,39 @@ export function extractAnnotationAttributes(region: AnnotationRegion): Annotatio }; } -/** Returns a new region with the copied attributes overwriting its own. Identity and - * timing (id, startMs, endMs) are preserved; nested objects are deep-copied. */ -export function applyZoomAttributes(region: ZoomRegion, attrs: ZoomAttributes): ZoomRegion { - return { - ...region, - depth: attrs.depth, - customScale: attrs.customScale, - focus: { ...attrs.focus }, - focusMode: attrs.focusMode, - rotationPreset: attrs.rotationPreset, - }; +/** Returns a region carrying the copied attributes. Identity, timing, and source come from + * `base` (so a full region keeps its own); every attribute comes from the copy, with nested + * objects deep-copied. Passing a stub `base` builds a brand-new region; passing an existing + * region overwrites ALL its attributes (e.g. a preset-only copy clears the target's customScale). */ +export function buildZoomRegion( + base: Pick, + attrs: CopiedZoom, +): ZoomRegion { + const { kind: _kind, ...zoomAttrs } = attrs; + return { ...base, ...zoomAttrs, focus: { ...zoomAttrs.focus } }; } -export function applySpeedAttributes(region: SpeedRegion, attrs: SpeedAttributes): SpeedRegion { - return { ...region, speed: attrs.speed }; +export function buildSpeedRegion( + base: Pick, + attrs: CopiedSpeed, +): SpeedRegion { + return { ...base, speed: attrs.speed }; } /** Pastes onto an EXISTING annotation: only the styling is overwritten — the target keeps * its own type, text/image content, position, timing, and stacking order. */ -export function applyAnnotationAttributes( +export function replaceAnnotationAttributes( region: AnnotationRegion, - attrs: AnnotationAttributes, + attrs: CopiedAnnotation, ): AnnotationRegion { return { ...region, style: { ...attrs.style }, size: { ...attrs.size }, - // Keep the target's own figure data when the copied region has none (e.g. text → figure). - figureData: attrs.figureData ? { ...attrs.figureData } : region.figureData, + // Only carry figure data onto a figure target; never attach it to a non-figure + // (e.g. pasting a figure's attributes onto a text annotation keeps the text figure-less). + figureData: + region.type === "figure" && attrs.figureData ? { ...attrs.figureData } : region.figureData, }; } @@ -116,7 +126,7 @@ export function applyAnnotationAttributes( * figure data, and position. Identity, timing, and stacking order come from `base`. */ export function buildPastedAnnotation( base: Pick, - attrs: AnnotationAttributes, + attrs: CopiedAnnotation, ): AnnotationRegion { return { ...base, diff --git a/src/components/video-editor/regionPlacement.test.ts b/src/components/video-editor/regionPlacement.test.ts new file mode 100644 index 000000000..6268776f9 --- /dev/null +++ b/src/components/video-editor/regionPlacement.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { findFreeGapAt } from "./regionPlacement"; + +const totalMs = 10000; + +describe("findFreeGapAt", () => { + it("returns the gap to the end when there are no regions", () => { + const { ok, gapMs } = findFreeGapAt([], 2000, totalMs); + expect(ok).toBe(true); + expect(gapMs).toBe(8000); + }); + + it("rejects a playhead that lands inside an existing region", () => { + const regions = [{ startMs: 1000, endMs: 4000 }]; + const { ok } = findFreeGapAt(regions, 2000, totalMs); + expect(ok).toBe(false); + }); + + it("clamps the gap to the next region's start", () => { + const regions = [{ startMs: 5000, endMs: 7000 }]; + const { ok, gapMs } = findFreeGapAt(regions, 2000, totalMs); + expect(ok).toBe(true); + expect(gapMs).toBe(3000); + }); + + it("rejects placement with no room before the end", () => { + const { ok, gapMs } = findFreeGapAt([], totalMs, totalMs); + expect(ok).toBe(false); + expect(gapMs).toBe(0); + }); + + it("allows placement adjacent to (exactly at the end of) an existing region", () => { + const regions = [{ startMs: 0, endMs: 2000 }]; + const { ok, gapMs } = findFreeGapAt(regions, 2000, totalMs); + expect(ok).toBe(true); + expect(gapMs).toBe(8000); + }); + + it("sorts unordered regions before computing the next gap", () => { + const regions = [ + { startMs: 8000, endMs: 9000 }, + { startMs: 3000, endMs: 4000 }, + ]; + const { ok, gapMs } = findFreeGapAt(regions, 1000, totalMs); + expect(ok).toBe(true); + expect(gapMs).toBe(2000); + }); +}); diff --git a/src/components/video-editor/regionPlacement.ts b/src/components/video-editor/regionPlacement.ts new file mode 100644 index 000000000..7343def0f --- /dev/null +++ b/src/components/video-editor/regionPlacement.ts @@ -0,0 +1,24 @@ +/** + * Find the available gap at `startPos` in a list of regions. + * + * Looks at the span from `startPos` up to the start of the next region + * (or up to `totalMs` if there is no later region) and reports its size, + * along with whether placement at `startPos` is actually valid. + * + * Placement is valid as long as `startPos` does not fall inside an + * existing region and there is some room before the next one. Landing + * exactly on the start of an existing region is fine (adjacency is + * allowed); landing strictly between a region's start and end, or + * having zero space left before the next region, is not. + */ +export function findFreeGapAt( + regions: ReadonlyArray<{ startMs: number; endMs: number }>, + startPos: number, + totalMs: number, +): { ok: boolean; gapMs: number } { + const sorted = [...regions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((r) => r.startMs > startPos); + const gapMs = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + const overlapping = sorted.some((r) => startPos >= r.startMs && startPos < r.endMs); + return { ok: !overlapping && gapMs > 0, gapMs }; +} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 17894ad1c..96965a012 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -25,11 +25,12 @@ import { import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { useAudioPeaks } from "@/hooks/useAudioPeaks"; -import { matchesShortcut } from "@/lib/shortcuts"; +import { isTextEditingTarget, matchesShortcut } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils"; import { formatShortcut } from "@/utils/platformUtils"; import { BLUR_REGIONS_ENABLED } from "../featureFlags"; +import { findFreeGapAt } from "../regionPlacement"; import type { AnnotationRegion, SpeedRegion, TrimRegion, ZoomRegion } from "../types"; import BackgroundWaveform from "./BackgroundWaveform"; import Item from "./Item"; @@ -1120,21 +1121,15 @@ export default function TimelineEditor({ } const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - if (isOverlapping || gapToNext <= 0) { + const { ok, gapMs } = findFreeGapAt(zoomRegions, startPos, totalMs); + if (!ok) { toast.error(t("errors.cannotPlaceZoom"), { description: t("errors.zoomExistsAtLocation"), }); return; } - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + const actualDuration = Math.min(defaultRegionDurationMs, gapMs); onZoomAdded({ start: startPos, end: startPos + actualDuration }); }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, t]); @@ -1149,21 +1144,15 @@ export default function TimelineEditor({ } const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - if (isOverlapping || gapToNext <= 0) { + const { ok, gapMs } = findFreeGapAt(trimRegions, startPos, totalMs); + if (!ok) { toast.error(t("errors.cannotPlaceTrim"), { description: t("errors.trimExistsAtLocation"), }); return; } - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + const actualDuration = Math.min(defaultRegionDurationMs, gapMs); onTrimAdded({ start: startPos, end: startPos + actualDuration }); }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, t]); @@ -1178,21 +1167,15 @@ export default function TimelineEditor({ } const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - if (isOverlapping || gapToNext <= 0) { + const { ok, gapMs } = findFreeGapAt(speedRegions, startPos, totalMs); + if (!ok) { toast.error(t("errors.cannotPlaceSpeed"), { description: t("errors.speedExistsAtLocation"), }); return; } - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); + const actualDuration = Math.min(defaultRegionDurationMs, gapMs); onSpeedAdded({ start: startPos, end: startPos + actualDuration }); }, [ videoDuration, @@ -1238,7 +1221,7 @@ export default function TimelineEditor({ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + if (isTextEditingTarget(e.target)) { return; } diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 3ba6a4eea..e658d08c2 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -83,7 +83,6 @@ "pasted": "تم لصق سمات {{region}}", "nothingToCopy": "حدد منطقة لنسخ سماتها", "nothingToPaste": "لم يتم نسخ أي سمات بعد", - "cannotPlace": "لا يمكن وضع المنطقة هنا: ستتداخل مع منطقة أخرى", "kinds": { "zoom": "تكبير", "speed": "سرعة", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index f3d9c1bc6..79b7d7126 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -83,7 +83,6 @@ "pasted": "{{region}} attributes pasted", "nothingToCopy": "Select a region to copy its attributes", "nothingToPaste": "No attributes copied yet", - "cannotPlace": "Can't place the region here — it would overlap another", "kinds": { "zoom": "Zoom", "speed": "Speed", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index bc12b8fd7..b028479a5 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -83,7 +83,6 @@ "pasted": "Atributos de {{region}} pegados", "nothingToCopy": "Selecciona una región para copiar sus atributos", "nothingToPaste": "Aún no se han copiado atributos", - "cannotPlace": "No se puede colocar la región aquí: se superpondría con otra", "kinds": { "zoom": "Zoom", "speed": "Velocidad", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index d3cc39523..e1a8e3e82 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -83,7 +83,6 @@ "pasted": "Attributs de {{region}} collés", "nothingToCopy": "Sélectionnez une région pour copier ses attributs", "nothingToPaste": "Aucun attribut copié pour l'instant", - "cannotPlace": "Impossible de placer la région ici : elle chevaucherait une autre", "kinds": { "zoom": "Zoom", "speed": "Vitesse", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 54edc4787..4cc2fe326 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -67,7 +67,6 @@ "pasted": "Attributi di {{region}} incollati", "nothingToCopy": "Seleziona una regione per copiarne gli attributi", "nothingToPaste": "Nessun attributo copiato", - "cannotPlace": "Impossibile posizionare la regione qui: si sovrapporrebbe a un'altra", "kinds": { "zoom": "Zoom", "speed": "Velocità", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 0c416c661..9291cdeae 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -83,7 +83,6 @@ "pasted": "{{region}}の属性を貼り付けました", "nothingToCopy": "属性をコピーする領域を選択してください", "nothingToPaste": "コピーされた属性がありません", - "cannotPlace": "ここには領域を配置できません(他の領域と重なります)", "kinds": { "zoom": "ズーム", "speed": "速度", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 2d432941e..ff52a36c9 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -83,7 +83,6 @@ "pasted": "{{region}} 속성을 붙여넣었습니다", "nothingToCopy": "속성을 복사할 영역을 선택하세요", "nothingToPaste": "복사된 속성이 없습니다", - "cannotPlace": "여기에는 영역을 배치할 수 없습니다. 다른 영역과 겹칩니다", "kinds": { "zoom": "줌", "speed": "속도", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index c78545713..29e1436cf 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -66,7 +66,6 @@ "pasted": "Atributos de {{region}} colados", "nothingToCopy": "Selecione uma região para copiar seus atributos", "nothingToPaste": "Nenhum atributo copiado ainda", - "cannotPlace": "Não é possível colocar a região aqui: ela se sobreporia a outra", "kinds": { "zoom": "Zoom", "speed": "Velocidade", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index c654c14dd..3820315ca 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -83,7 +83,6 @@ "pasted": "Атрибуты «{{region}}» вставлены", "nothingToCopy": "Выберите регион, чтобы скопировать его атрибуты", "nothingToPaste": "Атрибуты ещё не скопированы", - "cannotPlace": "Не удаётся разместить регион здесь: он перекрыл бы другой", "kinds": { "zoom": "Масштаб", "speed": "Скорость", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 7e69e891a..69d7311a8 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -83,7 +83,6 @@ "pasted": "{{region}} öznitelikleri yapıştırıldı", "nothingToCopy": "Özniteliklerini kopyalamak için bir bölge seçin", "nothingToPaste": "Henüz öznitelik kopyalanmadı", - "cannotPlace": "Bölge buraya yerleştirilemiyor: başka bir bölgeyle çakışır", "kinds": { "zoom": "Yakınlaştırma", "speed": "Hız", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index f754233dc..49fad41ff 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -83,7 +83,6 @@ "pasted": "Đã dán thuộc tính {{region}}", "nothingToCopy": "Chọn một vùng để sao chép thuộc tính của nó", "nothingToPaste": "Chưa sao chép thuộc tính nào", - "cannotPlace": "Không thể đặt vùng ở đây: nó sẽ chồng lên vùng khác", "kinds": { "zoom": "Thu phóng", "speed": "Tốc độ", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index fae1ff2aa..a5335e9cd 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -83,7 +83,6 @@ "pasted": "已粘贴{{region}}属性", "nothingToCopy": "选择一个区域以复制其属性", "nothingToPaste": "尚未复制任何属性", - "cannotPlace": "无法在此放置区域:会与其他区域重叠", "kinds": { "zoom": "缩放", "speed": "速度", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 9038f7e3a..724c7f5a4 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -83,7 +83,6 @@ "pasted": "已貼上{{region}}屬性", "nothingToCopy": "選擇一個區域以複製其屬性", "nothingToPaste": "尚未複製任何屬性", - "cannotPlace": "無法在此放置區域:會與其他區域重疊", "kinds": { "zoom": "縮放", "speed": "速度", diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 16129ef4c..2ebdb64c5 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -151,6 +151,15 @@ export function matchesShortcut( return true; } +/** True when the event target is a text-editing surface where shortcuts should not fire. */ +export function isTextEditingTarget(target: EventTarget | null): boolean { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ); +} + const KEY_LABELS: Record = { " ": "Space", delete: "Del", From d3e139102710aa657abec67766be6f0574734465 Mon Sep 17 00:00:00 2001 From: 446f6e6e79 <66618414+446f6e6e79@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:43:40 +0200 Subject: [PATCH 3/4] Update blur handling --- src/components/video-editor/VideoEditor.tsx | 38 ++++++++++++++----- .../video-editor/regionClipboard.ts | 16 ++++++++ .../video-editor/regionPlacement.ts | 6 +-- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 1ded512bb..91c9c624f 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1865,18 +1865,25 @@ export default function VideoEditor() { } // Copy/paste region attributes. Skipped while typing in a field so native - // text copy/paste keeps working. + // text copy/paste keeps working. Also only intercepted when there's an + // actual region selected (copy) or something on the clipboard (paste); + // otherwise the browser handles native copy/paste of any page selection. const editingText = isTextEditingTarget(e.target); if (!editingText) { if (matchesShortcut(e, shortcuts.copySelected, isMac)) { - e.preventDefault(); - handleCopySelected(); - return; - } - if (matchesShortcut(e, shortcuts.paste, isMac)) { - e.preventDefault(); - handlePaste(); - return; + const hasRegionSelected = + selectedZoomId || selectedSpeedId || selectedAnnotationId || selectedBlurId; + if (hasRegionSelected) { + e.preventDefault(); + handleCopySelected(); + return; + } + } else if (matchesShortcut(e, shortcuts.paste, isMac)) { + if (getCopiedRegion()) { + e.preventDefault(); + handlePaste(); + return; + } } } @@ -1936,7 +1943,18 @@ export default function VideoEditor() { window.addEventListener("keydown", handleKeyDown, { capture: true }); return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }, [undo, redo, shortcuts, isMac, handleCopySelected, handlePaste]); + }, [ + undo, + redo, + shortcuts, + isMac, + handleCopySelected, + handlePaste, + selectedZoomId, + selectedSpeedId, + selectedAnnotationId, + selectedBlurId, + ]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { diff --git a/src/components/video-editor/regionClipboard.ts b/src/components/video-editor/regionClipboard.ts index b1dd06c8c..876da4387 100644 --- a/src/components/video-editor/regionClipboard.ts +++ b/src/components/video-editor/regionClipboard.ts @@ -4,6 +4,7 @@ import type { AnnotationSize, AnnotationTextStyle, AnnotationType, + BlurData, FigureData, PlaybackSpeed, Rotation3DPreset, @@ -35,6 +36,7 @@ export type CopiedAnnotation = { style: AnnotationTextStyle; size: AnnotationSize; figureData?: FigureData; + blurData?: BlurData; // Content & placement — used only when pasting as a brand-new region. type: AnnotationType; content: string; @@ -72,12 +74,22 @@ export function extractSpeedAttributes(region: SpeedRegion): CopiedSpeed { return { kind: "speed", speed: region.speed }; } +/** Deep-clones blur data, including its nested freehand points array. */ +function cloneBlurData(blurData?: BlurData): BlurData | undefined { + if (!blurData) return undefined; + return { + ...blurData, + freehandPoints: blurData.freehandPoints ? [...blurData.freehandPoints] : undefined, + }; +} + export function extractAnnotationAttributes(region: AnnotationRegion): CopiedAnnotation { return { kind: "annotation", style: { ...region.style }, size: { ...region.size }, figureData: region.figureData ? { ...region.figureData } : undefined, + blurData: cloneBlurData(region.blurData), type: region.type, content: region.content, textContent: region.textContent, @@ -119,6 +131,9 @@ export function replaceAnnotationAttributes( // (e.g. pasting a figure's attributes onto a text annotation keeps the text figure-less). figureData: region.type === "figure" && attrs.figureData ? { ...attrs.figureData } : region.figureData, + // Likewise, only carry blur settings onto a blur target. + blurData: + region.type === "blur" && attrs.blurData ? cloneBlurData(attrs.blurData) : region.blurData, }; } @@ -138,5 +153,6 @@ export function buildPastedAnnotation( size: { ...attrs.size }, style: { ...attrs.style }, figureData: attrs.figureData ? { ...attrs.figureData } : undefined, + blurData: cloneBlurData(attrs.blurData), }; } diff --git a/src/components/video-editor/regionPlacement.ts b/src/components/video-editor/regionPlacement.ts index 7343def0f..673d3fe7f 100644 --- a/src/components/video-editor/regionPlacement.ts +++ b/src/components/video-editor/regionPlacement.ts @@ -7,9 +7,9 @@ * * Placement is valid as long as `startPos` does not fall inside an * existing region and there is some room before the next one. Landing - * exactly on the start of an existing region is fine (adjacency is - * allowed); landing strictly between a region's start and end, or - * having zero space left before the next region, is not. + * exactly on the end of an existing region is fine (adjacency is + * allowed); landing on a region's start or strictly between its start + * and end, or having zero space left before the next region, is not. */ export function findFreeGapAt( regions: ReadonlyArray<{ startMs: number; endMs: number }>, From dceb1fb77d25ce8a2e653667ee4b89ddb6e7a281 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sun, 28 Jun 2026 18:16:02 +0200 Subject: [PATCH 4/4] fix(video-editor): address CodeRabbit review of region clipboard - Fix handleCopySelected loop so stale ids fall through to the 'nothing to copy' toast instead of silently returning. - Use regionClipboard.kinds.blur label when copying a blur region. - Add 'blur' to regionClipboard.kinds in all 13 locales; broaden the 'annotation' label to neutral terms in es/fr/it/ja-JP/ko-KR/ pt-BR/ru/tr/zh-CN so the toast no longer reads 'Text' for blurs. - Cover the placement-at-region-start boundary in regionPlacement tests. --- src/components/video-editor/VideoEditor.tsx | 16 ++++++++++------ .../video-editor/regionPlacement.test.ts | 6 ++++++ src/i18n/locales/ar/editor.json | 3 ++- src/i18n/locales/en/editor.json | 3 ++- src/i18n/locales/es/editor.json | 3 ++- src/i18n/locales/fr/editor.json | 3 ++- src/i18n/locales/it/editor.json | 3 ++- src/i18n/locales/ja-JP/editor.json | 3 ++- src/i18n/locales/ko-KR/editor.json | 3 ++- src/i18n/locales/pt-BR/editor.json | 3 ++- src/i18n/locales/ru/editor.json | 3 ++- src/i18n/locales/tr/editor.json | 3 ++- src/i18n/locales/vi/editor.json | 3 ++- src/i18n/locales/zh-CN/editor.json | 3 ++- src/i18n/locales/zh-TW/editor.json | 3 ++- 15 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 91c9c624f..96da01f28 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1671,13 +1671,17 @@ export default function VideoEditor() { for (const [id, regions, extract, kind] of copyTargets) { if (!id) continue; const region = (regions as readonly { id: string }[]).find((r) => r.id === id); - if (region) { - // Each row pairs a region list with its matching extractor, so the cast is sound. - setCopiedRegion((extract as (r: never) => CopiedRegion)(region as never)); - toast.success(t("regionClipboard.copied", { region: t(`regionClipboard.kinds.${kind}`) }), { + if (!region) continue; // Stale id — try the next target so the fallback toast stays reachable. + // Each row pairs a region list with its matching extractor, so the cast is sound. + setCopiedRegion((extract as (r: never) => CopiedRegion)(region as never)); + // Blur lives in annotationRegions (type "blur") but its toast must label as "blur", not "text". + const labelKind = (region as { type?: string }).type === "blur" ? "blur" : kind; + toast.success( + t("regionClipboard.copied", { region: t(`regionClipboard.kinds.${labelKind}`) }), + { id: "regionClipboard.copied", - }); - } + }, + ); return; } toast.info(t("regionClipboard.nothingToCopy")); diff --git a/src/components/video-editor/regionPlacement.test.ts b/src/components/video-editor/regionPlacement.test.ts index 6268776f9..ecddf2ff6 100644 --- a/src/components/video-editor/regionPlacement.test.ts +++ b/src/components/video-editor/regionPlacement.test.ts @@ -29,6 +29,12 @@ describe("findFreeGapAt", () => { expect(gapMs).toBe(0); }); + it("rejects placement that lands exactly on a region's startMs", () => { + const regions = [{ startMs: 5000, endMs: 7000 }]; + const { ok } = findFreeGapAt(regions, 5000, totalMs); + expect(ok).toBe(false); + }); + it("allows placement adjacent to (exactly at the end of) an existing region", () => { const regions = [{ startMs: 0, endMs: 2000 }]; const { ok, gapMs } = findFreeGapAt(regions, 2000, totalMs); diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index e658d08c2..55ba4339c 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "تكبير", "speed": "سرعة", - "annotation": "نص" + "annotation": "نص", + "blur": "تمويه" } } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 79b7d7126..5810c45c6 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "Zoom", "speed": "Speed", - "annotation": "Text" + "annotation": "Text", + "blur": "Blur" } } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index b028479a5..c2c3a910e 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "Zoom", "speed": "Velocidad", - "annotation": "Texto" + "annotation": "Anotación", + "blur": "Desenfoque" } } } diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index e1a8e3e82..60d35285f 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "Zoom", "speed": "Vitesse", - "annotation": "Texte" + "annotation": "Annotation", + "blur": "Flou" } } } diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 4cc2fe326..41e6244ea 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -70,7 +70,8 @@ "kinds": { "zoom": "Zoom", "speed": "Velocità", - "annotation": "Testo" + "annotation": "Annotazione", + "blur": "Sfocatura" } } } diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 9291cdeae..9713c1a62 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "ズーム", "speed": "速度", - "annotation": "テキスト" + "annotation": "注釈", + "blur": "ぼかし" } } } diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index ff52a36c9..f3e995895 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "줌", "speed": "속도", - "annotation": "텍스트" + "annotation": "주석", + "blur": "블러" } } } diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 29e1436cf..2188ec15a 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -69,7 +69,8 @@ "kinds": { "zoom": "Zoom", "speed": "Velocidade", - "annotation": "Texto" + "annotation": "Anotação", + "blur": "Desfoque" } } } diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 3820315ca..c45501c32 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "Масштаб", "speed": "Скорость", - "annotation": "Текст" + "annotation": "Аннотация", + "blur": "Размытие" } } } diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 69d7311a8..32f10b22d 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "Yakınlaştırma", "speed": "Hız", - "annotation": "Metin" + "annotation": "Açıklama", + "blur": "Bulanıklık" } } } diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 49fad41ff..838599646 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "Thu phóng", "speed": "Tốc độ", - "annotation": "Văn bản" + "annotation": "Văn bản", + "blur": "Làm mờ" } } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index a5335e9cd..ae15accef 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "缩放", "speed": "速度", - "annotation": "文本" + "annotation": "标注", + "blur": "模糊" } } } diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 724c7f5a4..26d44016c 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -86,7 +86,8 @@ "kinds": { "zoom": "縮放", "speed": "速度", - "annotation": "文字" + "annotation": "文字", + "blur": "模糊" } } }