Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/components/video-editor/AnnotationOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getBlurOverlayColor,
getMosaicGridOverlayColor,
getNormalizedMosaicBlockSize,
normalizeBlurType,
} from "@/lib/blurEffects";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
Expand Down Expand Up @@ -85,7 +86,8 @@ export function AnnotationOverlay({
);
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
const blurType = "mosaic";
const blurType =
annotation.type === "blur" ? normalizeBlurType(annotation.blurData?.type) : "mosaic";
const blurOverlayColor =
annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
const mosaicGridOverlayColor =
Expand Down
41 changes: 30 additions & 11 deletions src/components/video-editor/BlurSettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Trash2 } from "lucide-react";
import { Copy, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { useScopedT } from "@/contexts/I18nContext";
Expand All @@ -19,13 +19,15 @@ interface BlurSettingsPanelProps {
blurRegion: AnnotationRegion;
onBlurDataChange: (blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onDuplicate?: () => void;
onDelete: () => void;
}

export function BlurSettingsPanel({
blurRegion,
onBlurDataChange,
onBlurDataCommit,
onDuplicate,
onDelete,
}: BlurSettingsPanelProps) {
const t = useScopedT("settings");
Expand All @@ -40,7 +42,10 @@ export function BlurSettingsPanel({
];

return (
<div className="min-w-0 p-4 flex flex-col h-full overflow-y-auto custom-scrollbar">
<div
data-testid="blur-settings-panel"
className="min-w-0 p-4 flex flex-col h-full overflow-y-auto custom-scrollbar"
>
<div className="mb-3">
<div className="mb-4">
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
Expand Down Expand Up @@ -178,15 +183,29 @@ export function BlurSettingsPanel({
/>
</div>

<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-4 grid grid-cols-2 gap-2">
<Button
data-testid="blur-duplicate-button"
onClick={() => onDuplicate?.()}
variant="outline"
size="sm"
disabled={!onDuplicate}
className="w-full gap-2 bg-white/5 text-slate-200 border border-white/10 hover:bg-white/10 hover:border-white/20 transition-all"
>
<Copy className="w-4 h-4" />
Duplicate
</Button>

<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all"
>
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
</div>
</div>
</div>
);
Expand Down
6 changes: 5 additions & 1 deletion src/components/video-editor/KeyboardShortcutsHelp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ export function KeyboardShortcutsHelp() {

return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
<HelpCircle
data-testid="keyboard-shortcuts-help"
className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help"
/>

<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-200">{t("title")}</span>
<button
type="button"
data-testid="keyboard-shortcuts-config-button"
onClick={openConfig}
title="Customize shortcuts"
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
Expand Down
3 changes: 3 additions & 0 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,9 @@ export function SettingsPanel({
blurRegion={selectedBlur}
onBlurDataChange={(blurData) => onBlurDataChange(selectedBlur.id, blurData)}
onBlurDataCommit={onBlurDataCommit}
onDuplicate={
onAnnotationDuplicate ? () => onAnnotationDuplicate(selectedBlur.id) : undefined
}
onDelete={() => onBlurDelete(selectedBlur.id)}
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/video-editor/ShortcutsConfigDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function ShortcutsConfigDialog() {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div key={action} data-testid={`shortcut-action-${action}`}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
<button
Expand Down
26 changes: 12 additions & 14 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import { SettingsPanel } from "./SettingsPanel";
import TimelineEditor from "./timeline/TimelineEditor";
import { buildAutoZoomSuggestions } from "./timeline/zoomSuggestionUtils";
import {
buildDuplicatedAnnotationRegion,
buildPastedAnnotationRegion,
buildPastedZoomRegion,
cloneAnnotationRegion,
Expand Down Expand Up @@ -1749,34 +1750,31 @@ export default function VideoEditor() {

const handleAnnotationDuplicate = useCallback(
(id: string) => {
const sourceType = annotationRegions.find((region) => region.id === id)?.type;
if (!sourceType) return;
const duplicateId = `annotation-${nextAnnotationIdRef.current++}`;
const duplicateZIndex = nextAnnotationZIndexRef.current++;
pushState((prev) => {
const source = prev.annotationRegions.find((region) => region.id === id);
if (!source) return {};

const { annotationSource: _stripCaptionLink, ...sourceWithoutCaptionLink } = source;

const duplicate: AnnotationRegion = {
...sourceWithoutCaptionLink,
id: duplicateId,
zIndex: duplicateZIndex,
position: { x: source.position.x + 4, y: source.position.y + 4 },
size: { ...source.size },
style: { ...source.style },
figureData: source.figureData ? { ...source.figureData } : undefined,
};
const duplicate = buildDuplicatedAnnotationRegion(source, duplicateId, duplicateZIndex);

return { annotationRegions: [...prev.annotationRegions, duplicate] };
});
setSelectedAnnotationId(duplicateId);
if (sourceType === "blur") {
setSelectedBlurId(duplicateId);
setSelectedAnnotationId(null);
} else {
setSelectedAnnotationId(duplicateId);
setSelectedBlurId(null);
}
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedBlurId(null);
setSelectedHighlightId(null);
},
[pushState],
[annotationRegions, pushState],
);

const handleAnnotationDelete = useCallback(
Expand Down
61 changes: 61 additions & 0 deletions src/components/video-editor/annotationRegionClone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { buildDuplicatedAnnotationRegion } from "./timelineClipboardUtils";
import type { AnnotationRegion } from "./types";

function createBlurRegion(overrides: Partial<AnnotationRegion> = {}): AnnotationRegion {
return {
id: "blur-1",
startMs: 100,
endMs: 900,
type: "blur",
content: "",
position: { x: 12, y: 18 },
size: { width: 32, height: 24 },
style: {
color: "#ffffff",
backgroundColor: "transparent",
fontSize: 32,
fontFamily: "Inter",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
textAlign: "center",
textAnimation: "none",
},
zIndex: 3,
annotationSource: "auto-caption",
blurData: {
type: "mosaic",
shape: "freehand",
color: "white",
intensity: 12,
blockSize: 8,
freehandPoints: [
{ x: 10, y: 20 },
{ x: 40, y: 30 },
{ x: 70, y: 80 },
],
},
...overrides,
};
}

describe("buildDuplicatedAnnotationRegion", () => {
it("deep clones duplicated blur data without sharing mutable references", () => {
const source = createBlurRegion();
const duplicate = buildDuplicatedAnnotationRegion(source, "blur-2", 9);

expect(duplicate.id).toBe("blur-2");
expect(duplicate.zIndex).toBe(9);
expect(duplicate.annotationSource).toBeUndefined();
expect(duplicate.position).toEqual({ x: 16, y: 22 });

expect(duplicate).not.toBe(source);
expect(duplicate.position).not.toBe(source.position);
expect(duplicate.size).not.toBe(source.size);
expect(duplicate.style).not.toBe(source.style);
expect(duplicate.blurData).not.toBe(source.blurData);
expect(duplicate.blurData?.freehandPoints).not.toBe(source.blurData?.freehandPoints);
expect(duplicate.blurData?.freehandPoints?.[0]).not.toBe(source.blurData?.freehandPoints?.[0]);
});
});
8 changes: 8 additions & 0 deletions src/components/video-editor/featureFlags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { BLUR_REGIONS_ENABLED } from "./featureFlags";

describe("video editor feature flags", () => {
it("enables blur region entry points", () => {
expect(BLUR_REGIONS_ENABLED).toBe(true);
});
});
2 changes: 1 addition & 1 deletion src/components/video-editor/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const BLUR_REGIONS_ENABLED = false;
export const BLUR_REGIONS_ENABLED = true;
Comment thread
My-Denia marked this conversation as resolved.
3 changes: 3 additions & 0 deletions src/components/video-editor/timeline/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export default function Item({
return (
<div
ref={setNodeRef}
data-timeline-item-id={id}
data-timeline-item-variant={variant}
data-selected={isSelected ? "true" : "false"}
style={safeItemStyle}
{...listeners}
{...attributes}
Expand Down
1 change: 1 addition & 0 deletions src/components/video-editor/timeline/Row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default function Row({ id, children, hint, isEmpty, background }: RowProp

return (
<div
data-timeline-row-id={id}
className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden"
style={{ ...rowWrapperStyle, minHeight: 36 }}
>
Expand Down
32 changes: 18 additions & 14 deletions src/components/video-editor/timeline/TimelineEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
import Row from "./Row";
import TimelineWrapper from "./TimelineWrapper";
import { getNextOverlappingRegionId } from "./timelineCycleSelection";

const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
Expand Down Expand Up @@ -788,6 +789,7 @@ function Timeline({

return (
<div
data-testid="timeline-editor-surface"
ref={setRefs}
style={{ ...style, touchAction: "none" }}
className="select-none bg-[#0b0c0f] min-h-[190px] relative cursor-pointer group"
Expand Down Expand Up @@ -1426,24 +1428,23 @@ export default function TimelineEditor({
handleAddHighlight();
}

// Tab cycles through overlapping annotations at the current time
if (e.key === "Tab" && annotationRegions.length > 0) {
// Tab cycles through overlapping items within the focused annotation/blur row.
if (e.key === "Tab") {
const currentTimeMs = Math.round(currentTime * 1000);
const overlapping = annotationRegions
.filter((a) => currentTimeMs >= a.startMs && currentTimeMs <= a.endMs)
.sort((a, b) => a.zIndex - b.zIndex);
const cycleBlurs = Boolean(selectedBlurId);
const nextId = getNextOverlappingRegionId({
regions: cycleBlurs ? blurRegions : annotationRegions,
selectedId: cycleBlurs ? selectedBlurId : selectedAnnotationId,
currentTimeMs,
backward: e.shiftKey,
});

if (overlapping.length > 0) {
if (nextId) {
e.preventDefault();

if (!selectedAnnotationId || !overlapping.some((a) => a.id === selectedAnnotationId)) {
onSelectAnnotation?.(overlapping[0].id);
if (cycleBlurs) {
onSelectBlur?.(nextId);
} else {
const currentIndex = overlapping.findIndex((a) => a.id === selectedAnnotationId);
const nextIndex = e.shiftKey
? (currentIndex - 1 + overlapping.length) % overlapping.length // Shift+Tab steps backward
: (currentIndex + 1) % overlapping.length;
onSelectAnnotation?.(overlapping[nextIndex].id);
onSelectAnnotation?.(nextId);
}
}
}
Expand Down Expand Up @@ -1496,8 +1497,10 @@ export default function TimelineEditor({
selectedSpeedId,
selectedHighlightId,
annotationRegions,
blurRegions,
currentTime,
onSelectAnnotation,
onSelectBlur,
keyShortcuts,
canCopySelectedItem,
canPasteTimelineItem,
Expand Down Expand Up @@ -1724,6 +1727,7 @@ export default function TimelineEditor({
</Button>
{BLUR_REGIONS_ENABLED && (
<Button
data-testid="timeline-add-blur-button"
onClick={handleAddBlur}
variant="ghost"
size="icon"
Expand Down
Loading
Loading