diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 445a09060..96da01f28 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, @@ -87,6 +87,19 @@ import { toFileUrl, validateProjectData, } from "./projectPersistence"; +import { + 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"; @@ -313,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); @@ -1640,6 +1654,202 @@ export default function VideoEditor() { [pushState], ); + const handleCopySelected = useCallback(() => { + // 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) 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")); + }, [ + selectedZoomId, + selectedSpeedId, + selectedAnnotationId, + selectedBlurId, + zoomRegions, + speedRegions, + annotationRegions, + t, + ]); + + const handlePaste = useCallback(() => { + const copied = getCopiedRegion(); + // If there's nothing in the clipboard, show a message and return early. + if (!copied) { + toast.info(t("regionClipboard.nothingToPaste")); + return; + } + + // 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 ? buildZoomRegion(r, copied) : r, + ), + })); + 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 ? buildSpeedRegion(r, copied) : r, + ), + })); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + return; + } + // 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 === targetId ? replaceAnnotationAttributes(r, copied) : r, + ), + })); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + 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 { 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 = buildZoomRegion( + { + id, + startMs: startPos, + endMs: startPos + Math.min(defaultDuration, gapMs), + source: "manual", + }, + copied, + ); + pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, region] })); + handleSelectZoom(id); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + return; + } + + if (copied.kind === "speed") { + 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 = buildSpeedRegion( + { + id, + startMs: startPos, + endMs: startPos + Math.min(defaultDuration, gapMs), + }, + copied, + ); + pushState((prev) => ({ speedRegions: [...prev.speedRegions, region] })); + handleSelectSpeed(id); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + 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); + toast.success( + t("regionClipboard.pasted", { region: t(`regionClipboard.kinds.${copied.kind}`) }), + { + id: "regionClipboard.pasted", + }, + ); + }, [ + selectedZoomId, + selectedSpeedId, + selectedAnnotationId, + selectedBlurId, + zoomRegions, + speedRegions, + duration, + currentTime, + pushState, + handleSelectZoom, + handleSelectSpeed, + handleSelectAnnotation, + t, + tt, + ]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const mod = e.ctrlKey || e.metaKey; @@ -1658,6 +1868,29 @@ export default function VideoEditor() { return; } + // Copy/paste region attributes. Skipped while typing in a field so native + // 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)) { + 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; + } + } + } + // Frame-step navigation (arrow keys, no modifiers) if ( (e.key === "ArrowLeft" || e.key === "ArrowRight") && @@ -1714,7 +1947,18 @@ 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, + selectedZoomId, + selectedSpeedId, + selectedAnnotationId, + selectedBlurId, + ]); 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..f99f17eb2 --- /dev/null +++ b/src/components/video-editor/regionClipboard.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { + buildPastedAnnotation, + buildSpeedRegion, + buildZoomRegion, + extractAnnotationAttributes, + extractSpeedAttributes, + extractZoomAttributes, + replaceAnnotationAttributes, +} 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 = buildZoomRegion(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 = buildZoomRegion({ ...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 = buildSpeedRegion(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 = replaceAnnotationAttributes(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 }); + // 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 = replaceAnnotationAttributes(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); + }); +}); + +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 new file mode 100644 index 000000000..876da4387 --- /dev/null +++ b/src/components/video-editor/regionClipboard.ts @@ -0,0 +1,158 @@ +import type { + AnnotationPosition, + AnnotationRegion, + AnnotationSize, + AnnotationTextStyle, + AnnotationType, + BlurData, + FigureData, + PlaybackSpeed, + Rotation3DPreset, + SpeedRegion, + ZoomDepth, + ZoomFocus, + ZoomFocusMode, + ZoomRegion, +} from "./types"; + +/** 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; +}; + +export type CopiedSpeed = { kind: "speed"; speed: PlaybackSpeed }; + +/** 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; + size: AnnotationSize; + figureData?: FigureData; + blurData?: BlurData; + // Content & placement — used only when pasting as a brand-new region. + type: AnnotationType; + content: string; + 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 function setCopiedRegion(region: CopiedRegion): void { + clipboard = region; +} + +export function extractZoomAttributes(region: ZoomRegion): CopiedZoom { + return { + kind: "zoom", + depth: region.depth, + customScale: region.customScale, + focus: { ...region.focus }, + focusMode: region.focusMode, + rotationPreset: region.rotationPreset, + }; +} + +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, + imageContent: region.imageContent, + position: { ...region.position }, + }; +} + +/** 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 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 replaceAnnotationAttributes( + region: AnnotationRegion, + attrs: CopiedAnnotation, +): AnnotationRegion { + return { + ...region, + style: { ...attrs.style }, + size: { ...attrs.size }, + // 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, + // Likewise, only carry blur settings onto a blur target. + blurData: + region.type === "blur" && attrs.blurData ? cloneBlurData(attrs.blurData) : region.blurData, + }; +} + +/** 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: CopiedAnnotation, +): 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, + blurData: cloneBlurData(attrs.blurData), + }; +} diff --git a/src/components/video-editor/regionPlacement.test.ts b/src/components/video-editor/regionPlacement.test.ts new file mode 100644 index 000000000..ecddf2ff6 --- /dev/null +++ b/src/components/video-editor/regionPlacement.test.ts @@ -0,0 +1,54 @@ +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("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); + 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..673d3fe7f --- /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 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 }>, + 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 39750e5eb..55ba4339c 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": "لم يتم نسخ أي سمات بعد", + "kinds": { + "zoom": "تكبير", + "speed": "سرعة", + "annotation": "نص", + "blur": "تمويه" + } } } 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..5810c45c6 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", + "kinds": { + "zoom": "Zoom", + "speed": "Speed", + "annotation": "Text", + "blur": "Blur" + } } } 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..c2c3a910e 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", + "kinds": { + "zoom": "Zoom", + "speed": "Velocidad", + "annotation": "Anotación", + "blur": "Desenfoque" + } } } 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..60d35285f 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", + "kinds": { + "zoom": "Zoom", + "speed": "Vitesse", + "annotation": "Annotation", + "blur": "Flou" + } } } 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..41e6244ea 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", + "kinds": { + "zoom": "Zoom", + "speed": "Velocità", + "annotation": "Annotazione", + "blur": "Sfocatura" + } } } 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..9713c1a62 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": "コピーされた属性がありません", + "kinds": { + "zoom": "ズーム", + "speed": "速度", + "annotation": "注釈", + "blur": "ぼかし" + } } } 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..f3e995895 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": "복사된 속성이 없습니다", + "kinds": { + "zoom": "줌", + "speed": "속도", + "annotation": "주석", + "blur": "블러" + } } } 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..2188ec15a 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", + "kinds": { + "zoom": "Zoom", + "speed": "Velocidade", + "annotation": "Anotação", + "blur": "Desfoque" + } } } 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..c45501c32 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": "Атрибуты ещё не скопированы", + "kinds": { + "zoom": "Масштаб", + "speed": "Скорость", + "annotation": "Аннотация", + "blur": "Размытие" + } } } 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..32f10b22d 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ı", + "kinds": { + "zoom": "Yakınlaştırma", + "speed": "Hız", + "annotation": "Açıklama", + "blur": "Bulanıklık" + } } } 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..838599646 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", + "kinds": { + "zoom": "Thu phóng", + "speed": "Tốc độ", + "annotation": "Văn bản", + "blur": "Làm mờ" + } } } 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..ae15accef 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": "尚未复制任何属性", + "kinds": { + "zoom": "缩放", + "speed": "速度", + "annotation": "标注", + "blur": "模糊" + } } } 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..26d44016c 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": "尚未複製任何屬性", + "kinds": { + "zoom": "縮放", + "speed": "速度", + "annotation": "文字", + "blur": "模糊" + } } } 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..2ebdb64c5 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( @@ -145,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",