Merge branch 'main' into feat/i18n-ko-KR
This commit is contained in:
Generated
+398
-498
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,28 @@
|
||||
import { useRef } from "react";
|
||||
import { type CSSProperties, type PointerEvent, useRef, useState } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion } from "./types";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
const FREEHAND_POINT_THRESHOLD = 1;
|
||||
|
||||
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
|
||||
if (points.length < 3) return undefined;
|
||||
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
|
||||
return `polygon(${polygon})`;
|
||||
}
|
||||
|
||||
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
|
||||
if (closed ? points.length < 3 : points.length < 2) return null;
|
||||
const [firstPoint, ...rest] = points;
|
||||
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
|
||||
return closed ? `${path} Z` : path;
|
||||
}
|
||||
|
||||
interface AnnotationOverlayProps {
|
||||
annotation: AnnotationRegion;
|
||||
@@ -11,6 +31,8 @@ interface AnnotationOverlayProps {
|
||||
containerHeight: number;
|
||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onClick: (id: string) => void;
|
||||
zIndex: number;
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
@@ -23,6 +45,8 @@ export function AnnotationOverlay({
|
||||
containerHeight,
|
||||
onPositionChange,
|
||||
onSizeChange,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
@@ -31,8 +55,16 @@ export function AnnotationOverlay({
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
|
||||
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
|
||||
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
|
||||
const isDraggingRef = useRef(false);
|
||||
const isDrawingFreehandRef = useRef(false);
|
||||
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
|
||||
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
|
||||
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
|
||||
[],
|
||||
);
|
||||
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || "right";
|
||||
@@ -43,6 +75,95 @@ export function AnnotationOverlay({
|
||||
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
|
||||
};
|
||||
|
||||
const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = ((event.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((event.clientY - rect.top) / rect.height) * 100;
|
||||
return {
|
||||
x: Math.max(0, Math.min(100, x)),
|
||||
y: Math.max(0, Math.min(100, y)),
|
||||
};
|
||||
};
|
||||
|
||||
const appendFreehandPoint = (point: { x: number; y: number }) => {
|
||||
const points = freehandPointsRef.current;
|
||||
const lastPoint = points[points.length - 1];
|
||||
if (!lastPoint) {
|
||||
points.push(point);
|
||||
return;
|
||||
}
|
||||
const dx = point.x - lastPoint.x;
|
||||
const dy = point.y - lastPoint.y;
|
||||
// Sample freehand points in annotation-space percent units to avoid overly dense paths.
|
||||
if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
|
||||
points.push(point);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFreehandPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!isSelected ||
|
||||
annotation.type !== "blur" ||
|
||||
annotation.blurData?.shape !== "freehand" ||
|
||||
!onBlurDataChange
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
isDrawingFreehandRef.current = true;
|
||||
setIsFreehandDrawing(true);
|
||||
const point = normalizePoint(event);
|
||||
freehandPointsRef.current = [point];
|
||||
setDraftFreehandPoints([point]);
|
||||
setLivePointerPoint(point);
|
||||
};
|
||||
|
||||
const handleFreehandPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!isDrawingFreehandRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const point = normalizePoint(event);
|
||||
setLivePointerPoint(point);
|
||||
appendFreehandPoint(point);
|
||||
setDraftFreehandPoints([...freehandPointsRef.current]);
|
||||
};
|
||||
|
||||
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
|
||||
isDrawingFreehandRef.current = false;
|
||||
setIsFreehandDrawing(false);
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// no-op if already released
|
||||
}
|
||||
const points = [...freehandPointsRef.current];
|
||||
if (livePointerPoint) {
|
||||
const last = points[points.length - 1];
|
||||
if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
|
||||
points.push(livePointerPoint);
|
||||
}
|
||||
}
|
||||
if (points.length >= 3) {
|
||||
const closedPoints = [...points];
|
||||
const first = closedPoints[0];
|
||||
const last = closedPoints[closedPoints.length - 1];
|
||||
if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
|
||||
closedPoints.push({ ...first });
|
||||
}
|
||||
onBlurDataChange(annotation.id, {
|
||||
...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
|
||||
shape: "freehand",
|
||||
freehandPoints: closedPoints,
|
||||
});
|
||||
setDraftFreehandPoints(closedPoints);
|
||||
onBlurDataCommit?.();
|
||||
}
|
||||
setLivePointerPoint(null);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (annotation.type) {
|
||||
case "text":
|
||||
@@ -113,6 +234,114 @@ export function AnnotationOverlay({
|
||||
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
|
||||
);
|
||||
|
||||
case "blur": {
|
||||
const shape = annotation.blurData?.shape ?? "rectangle";
|
||||
const blurIntensity = Math.max(
|
||||
1,
|
||||
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
|
||||
);
|
||||
const activeFreehandPoints =
|
||||
shape === "freehand"
|
||||
? isFreehandDrawing
|
||||
? draftFreehandPoints
|
||||
: (annotation.blurData?.freehandPoints ?? [])
|
||||
: [];
|
||||
const drawingPoints =
|
||||
isFreehandDrawing && livePointerPoint
|
||||
? (() => {
|
||||
const last = activeFreehandPoints[activeFreehandPoints.length - 1];
|
||||
if (!last) return [livePointerPoint];
|
||||
const dx = livePointerPoint.x - last.x;
|
||||
const dy = livePointerPoint.y - last.y;
|
||||
return Math.hypot(dx, dy) > 0.01
|
||||
? [...activeFreehandPoints, livePointerPoint]
|
||||
: activeFreehandPoints;
|
||||
})()
|
||||
: activeFreehandPoints;
|
||||
const clipPath =
|
||||
shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
|
||||
const freehandPath =
|
||||
shape === "freehand"
|
||||
? buildBlurFreehandPath(
|
||||
isFreehandDrawing ? drawingPoints : activeFreehandPoints,
|
||||
!isFreehandDrawing,
|
||||
)
|
||||
: null;
|
||||
const currentPointerPoint = isFreehandDrawing
|
||||
? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
|
||||
: null;
|
||||
const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
|
||||
const shouldShowFreehandBlurFill =
|
||||
shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
|
||||
const shapeMaskStyle: CSSProperties = {
|
||||
borderRadius: shapeBorderRadius,
|
||||
clipPath: isFreehandDrawing ? undefined : clipPath,
|
||||
WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
|
||||
};
|
||||
const isFreehandSelected = isSelectedFreehandBlur;
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
isolation: "isolate",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
backdropFilter: `blur(${blurIntensity}px)`,
|
||||
WebkitBackdropFilter: `blur(${blurIntensity}px)`,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.02)",
|
||||
opacity: shouldShowFreehandBlurFill ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
{isSelected && shape !== "freehand" && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
|
||||
style={{ borderRadius: shapeBorderRadius }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && shape === "freehand" && freehandPath && (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
>
|
||||
<path
|
||||
d={freehandPath}
|
||||
fill="none"
|
||||
stroke="#34B27B"
|
||||
strokeWidth="0.55"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{currentPointerPoint && (
|
||||
<circle
|
||||
cx={currentPointerPoint.x}
|
||||
cy={currentPointerPoint.y}
|
||||
r="0.6"
|
||||
fill="#34B27B"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
{isFreehandSelected && (
|
||||
<div
|
||||
className="absolute inset-0 cursor-crosshair"
|
||||
onPointerDown={handleFreehandPointerDown}
|
||||
onPointerMove={handleFreehandPointerMove}
|
||||
onPointerUp={finishFreehandPointer}
|
||||
onPointerCancel={finishFreehandPointer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -149,18 +378,23 @@ export function AnnotationOverlay({
|
||||
}}
|
||||
bounds="parent"
|
||||
className={cn(
|
||||
"cursor-move transition-all",
|
||||
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
"cursor-move",
|
||||
isSelected &&
|
||||
annotation.type !== "blur" &&
|
||||
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
||||
pointerEvents: isSelected ? "auto" : "none",
|
||||
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
border:
|
||||
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor:
|
||||
isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow:
|
||||
isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
}}
|
||||
enableResizing={isSelected}
|
||||
disableDragging={!isSelected}
|
||||
enableResizing={isSelected && !isSelectedFreehandBlur}
|
||||
disableDragging={!isSelected || isSelectedFreehandBlur}
|
||||
resizeHandleStyles={{
|
||||
topLeft: {
|
||||
width: "12px",
|
||||
@@ -206,11 +440,13 @@ export function AnnotationOverlay({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full rounded-lg",
|
||||
"w-full h-full",
|
||||
annotation.type !== "blur" && "rounded-lg",
|
||||
annotation.type === "text" && "bg-transparent",
|
||||
annotation.type === "image" && "bg-transparent",
|
||||
annotation.type === "figure" && "bg-transparent",
|
||||
isSelected && "shadow-lg",
|
||||
annotation.type === "blur" && "bg-transparent",
|
||||
isSelected && annotation.type !== "blur" && "shadow-lg",
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
|
||||
@@ -32,7 +32,12 @@ import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type AnnotationType,
|
||||
type ArrowDirection,
|
||||
type FigureData,
|
||||
} from "./types";
|
||||
|
||||
interface AnnotationSettingsPanelProps {
|
||||
annotation: AnnotationRegion;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Info, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type BlurShape,
|
||||
DEFAULT_BLUR_DATA,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MIN_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
interface BlurSettingsPanelProps {
|
||||
blurRegion: AnnotationRegion;
|
||||
onBlurDataChange: (blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function BlurSettingsPanel({
|
||||
blurRegion,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onDelete,
|
||||
}: BlurSettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
|
||||
const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
|
||||
{ value: "rectangle", labelKey: "blurShapeRectangle" },
|
||||
{ value: "oval", labelKey: "blurShapeOval" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||
{t("annotation.active")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{blurShapeOptions.map((shape) => {
|
||||
const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
|
||||
const isActive = activeShape === shape.value;
|
||||
return (
|
||||
<button
|
||||
key={shape.value}
|
||||
onClick={() => {
|
||||
const nextBlurData: BlurData = {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
shape: shape.value,
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
requestAnimationFrame(() => {
|
||||
onBlurDataCommit?.();
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
|
||||
isActive
|
||||
? "bg-[#34B27B] border-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
{shape.value === "rectangle" && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-5 border-2 rounded-sm",
|
||||
isActive ? "border-white" : "border-slate-400",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{shape.value === "oval" && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-5 border-2 rounded-full",
|
||||
isActive ? "border-white" : "border-slate-400",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[10px] leading-none">
|
||||
{t(`annotation.${shape.labelKey}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-slate-300">
|
||||
{t("annotation.blurIntensity")}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity]}
|
||||
onValueChange={(values) => {
|
||||
onBlurDataChange({
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
intensity: values[0],
|
||||
});
|
||||
}}
|
||||
onValueCommit={() => onBlurDataCommit?.()}
|
||||
min={MIN_BLUR_INTENSITY}
|
||||
max={MAX_BLUR_INTENSITY}
|
||||
step={1}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</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-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
|
||||
</div>
|
||||
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
||||
<li>{t("annotation.tipMovePlayhead")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,11 +42,13 @@ import { cn } from "@/lib/utils";
|
||||
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { getTestId } from "@/utils/getTestId";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { BlurSettingsPanel } from "./BlurSettingsPanel";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
AnnotationType,
|
||||
BlurData,
|
||||
CropRegion,
|
||||
FigureData,
|
||||
PlaybackSpeed,
|
||||
@@ -209,6 +211,11 @@ interface SettingsPanelProps {
|
||||
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
|
||||
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedBlurId?: string | null;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onBlurDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
selectedSpeedValue?: PlaybackSpeed | null;
|
||||
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
||||
@@ -285,6 +292,11 @@ export function SettingsPanel({
|
||||
onAnnotationStyleChange,
|
||||
onAnnotationFigureDataChange,
|
||||
onAnnotationDelete,
|
||||
selectedBlurId,
|
||||
blurRegions = [],
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onBlurDelete,
|
||||
selectedSpeedId,
|
||||
selectedSpeedValue,
|
||||
onSpeedChange,
|
||||
@@ -520,6 +532,9 @@ export function SettingsPanel({
|
||||
const selectedAnnotation = selectedAnnotationId
|
||||
? annotationRegions.find((a) => a.id === selectedAnnotationId)
|
||||
: null;
|
||||
const selectedBlur = selectedBlurId
|
||||
? blurRegions.find((region) => region.id === selectedBlurId)
|
||||
: null;
|
||||
|
||||
// If an annotation is selected, show annotation settings instead
|
||||
if (
|
||||
@@ -545,6 +560,17 @@ export function SettingsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBlur && onBlurDataChange && onBlurDelete) {
|
||||
return (
|
||||
<BlurSettingsPanel
|
||||
blurRegion={selectedBlur}
|
||||
onBlurDataChange={(blurData) => onBlurDataChange(selectedBlur.id, blurData)}
|
||||
onBlurDataCommit={onBlurDataCommit}
|
||||
onDelete={() => onBlurDelete(selectedBlur.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
|
||||
|
||||
@@ -54,11 +54,13 @@ import { SettingsPanel } from "./SettingsPanel";
|
||||
import TimelineEditor from "./timeline/TimelineEditor";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type CursorTelemetryPoint,
|
||||
clampFocusToDepth,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
@@ -122,6 +124,7 @@ export default function VideoEditor() {
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [selectedBlurId, setSelectedBlurId] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
@@ -157,6 +160,15 @@ export default function VideoEditor() {
|
||||
const nextAnnotationZIndexRef = useRef(1);
|
||||
const exporterRef = useRef<VideoExporter | null>(null);
|
||||
|
||||
const annotationOnlyRegions = useMemo(
|
||||
() => annotationRegions.filter((region) => region.type !== "blur"),
|
||||
[annotationRegions],
|
||||
);
|
||||
const blurRegions = useMemo(
|
||||
() => annotationRegions.filter((region) => region.type === "blur"),
|
||||
[annotationRegions],
|
||||
);
|
||||
|
||||
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
|
||||
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
|
||||
if (!screenVideoPath) {
|
||||
@@ -229,6 +241,7 @@ export default function VideoEditor() {
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
|
||||
nextZoomIdRef.current = deriveNextId(
|
||||
"zoom",
|
||||
@@ -626,7 +639,11 @@ export default function VideoEditor() {
|
||||
|
||||
const handleSelectZoom = useCallback((id: string | null) => {
|
||||
setSelectedZoomId(id);
|
||||
if (id) setSelectedTrimId(null);
|
||||
if (id) {
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectTrim = useCallback((id: string | null) => {
|
||||
@@ -634,6 +651,7 @@ export default function VideoEditor() {
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -642,6 +660,17 @@ export default function VideoEditor() {
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectBlur = useCallback((id: string | null) => {
|
||||
setSelectedBlurId(id);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedSpeedId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -659,6 +688,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -677,6 +707,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -693,6 +724,7 @@ export default function VideoEditor() {
|
||||
setSelectedTrimId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -803,6 +835,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -822,6 +855,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -888,6 +922,35 @@ export default function VideoEditor() {
|
||||
setSelectedAnnotationId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleBlurAdded = useCallback(
|
||||
(span: Span) => {
|
||||
const id = `annotation-${nextAnnotationIdRef.current++}`;
|
||||
const zIndex = nextAnnotationZIndexRef.current++;
|
||||
const newRegion: AnnotationRegion = {
|
||||
id,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
type: "blur",
|
||||
content: "",
|
||||
position: { ...DEFAULT_ANNOTATION_POSITION },
|
||||
size: { ...DEFAULT_ANNOTATION_SIZE },
|
||||
style: { ...DEFAULT_ANNOTATION_STYLE },
|
||||
zIndex,
|
||||
blurData: { ...DEFAULT_BLUR_DATA },
|
||||
};
|
||||
pushState((prev) => ({
|
||||
annotationRegions: [...prev.annotationRegions, newRegion],
|
||||
}));
|
||||
setSelectedBlurId(id);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -917,8 +980,11 @@ export default function VideoEditor() {
|
||||
if (selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
if (selectedBlurId === id) {
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
},
|
||||
[selectedAnnotationId, pushState],
|
||||
[selectedAnnotationId, selectedBlurId, pushState],
|
||||
);
|
||||
|
||||
const handleAnnotationContentChange = useCallback(
|
||||
@@ -953,12 +1019,26 @@ export default function VideoEditor() {
|
||||
if (!region.figureData) {
|
||||
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
|
||||
}
|
||||
} else if (type === "blur") {
|
||||
updatedRegion.content = "";
|
||||
if (!region.blurData) {
|
||||
updatedRegion.blurData = { ...DEFAULT_BLUR_DATA };
|
||||
}
|
||||
}
|
||||
return updatedRegion;
|
||||
}),
|
||||
}));
|
||||
|
||||
if (type === "blur" && selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(id);
|
||||
setSelectedSpeedId(null);
|
||||
} else if (type !== "blur" && selectedBlurId === id) {
|
||||
setSelectedBlurId(null);
|
||||
setSelectedAnnotationId(id);
|
||||
}
|
||||
},
|
||||
[pushState],
|
||||
[pushState, selectedAnnotationId, selectedBlurId],
|
||||
);
|
||||
|
||||
const handleAnnotationStyleChange = useCallback(
|
||||
@@ -983,6 +1063,51 @@ export default function VideoEditor() {
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleBlurDataPreviewChange = useCallback(
|
||||
(id: string, blurData: BlurData) => {
|
||||
updateState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
blurData,
|
||||
// Freehand drawing area is the full video surface.
|
||||
...(blurData.shape === "freehand"
|
||||
? {
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const handleBlurDataPanelChange = useCallback(
|
||||
(id: string, blurData: BlurData) => {
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
blurData,
|
||||
...(blurData.shape === "freehand"
|
||||
? {
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleAnnotationPositionChange = useCallback(
|
||||
(id: string, position: { x: number; y: number }) => {
|
||||
pushState((prev) => ({
|
||||
@@ -1096,11 +1221,14 @@ export default function VideoEditor() {
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedAnnotationId &&
|
||||
!annotationRegions.some((region) => region.id === selectedAnnotationId)
|
||||
!annotationOnlyRegions.some((region) => region.id === selectedAnnotationId)
|
||||
) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, [selectedAnnotationId, annotationRegions]);
|
||||
if (selectedBlurId && !blurRegions.some((region) => region.id === selectedBlurId)) {
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, [selectedAnnotationId, selectedBlurId, annotationOnlyRegions, blurRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
|
||||
@@ -1675,11 +1803,18 @@ export default function VideoEditor() {
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
blurRegions={blurRegions}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onBlurPositionChange={handleAnnotationPositionChange}
|
||||
onBlurSizeChange={handleAnnotationSizeChange}
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
/>
|
||||
</div>
|
||||
@@ -1732,12 +1867,18 @@ export default function VideoEditor() {
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
blurRegions={blurRegions}
|
||||
onBlurAdded={handleBlurAdded}
|
||||
onBlurSpanChange={handleAnnotationSpanChange}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={(ar) =>
|
||||
pushState({
|
||||
@@ -1830,12 +1971,17 @@ export default function VideoEditor() {
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
blurRegions={blurRegions}
|
||||
onBlurDataChange={handleBlurDataPanelChange}
|
||||
onBlurDataCommit={commitState}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { AnnotationOverlay } from "./AnnotationOverlay";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
@@ -101,6 +102,13 @@ interface VideoPlaybackProps {
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
selectedBlurId?: string | null;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||
onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
|
||||
}
|
||||
|
||||
@@ -152,6 +160,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onSelectAnnotation,
|
||||
onAnnotationPositionChange,
|
||||
onAnnotationSizeChange,
|
||||
blurRegions = [],
|
||||
selectedBlurId,
|
||||
onSelectBlur,
|
||||
onBlurPositionChange,
|
||||
onBlurSizeChange,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
cursorTelemetry = [],
|
||||
},
|
||||
ref,
|
||||
@@ -166,6 +181,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const timeUpdateAnimationRef = useRef<number | null>(null);
|
||||
const [pixiReady, setPixiReady] = useState(false);
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
|
||||
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
|
||||
@@ -330,6 +347,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
layoutVideoContentRef.current = layoutVideoContent;
|
||||
}, [layoutVideoContent]);
|
||||
|
||||
const setOverlayRefs = useCallback((node: HTMLDivElement | null) => {
|
||||
overlayRef.current = node;
|
||||
setOverlayElement(node);
|
||||
}, []);
|
||||
|
||||
const selectedZoom = useMemo(() => {
|
||||
if (!selectedZoomId) return null;
|
||||
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
|
||||
@@ -623,7 +645,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
const overlayEl = overlayRef.current;
|
||||
if (!pixiReady || !videoReady) return;
|
||||
const overlayEl = overlayElement;
|
||||
if (!overlayEl) return;
|
||||
if (!selectedZoom) {
|
||||
overlayEl.style.cursor = "default";
|
||||
@@ -632,7 +655,34 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab";
|
||||
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
|
||||
}, [selectedZoom, isPlaying]);
|
||||
}, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const overlayEl = overlayElement;
|
||||
if (!overlayEl) return;
|
||||
|
||||
const updateOverlaySize = () => {
|
||||
const width = overlayEl.clientWidth || 800;
|
||||
const height = overlayEl.clientHeight || 600;
|
||||
setOverlaySize((prev) => {
|
||||
if (prev.width === width && prev.height === height) return prev;
|
||||
return { width, height };
|
||||
});
|
||||
};
|
||||
|
||||
updateOverlaySize();
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateOverlaySize();
|
||||
});
|
||||
observer.observe(overlayEl);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
window.addEventListener("resize", updateOverlaySize);
|
||||
return () => window.removeEventListener("resize", updateOverlaySize);
|
||||
}, [overlayElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -1287,7 +1337,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
ref={setOverlayRefs}
|
||||
className="absolute inset-0 select-none"
|
||||
style={{ pointerEvents: "none", zIndex: 30 }}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
@@ -1301,7 +1351,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
style={{ display: "none", pointerEvents: "none" }}
|
||||
/>
|
||||
{(() => {
|
||||
const filtered = (annotationRegions || []).filter((annotation) => {
|
||||
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
|
||||
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
|
||||
return false;
|
||||
|
||||
@@ -1311,37 +1361,93 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
|
||||
});
|
||||
|
||||
// Sort by z-index (lowest to highest) so higher z-index renders on top
|
||||
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
|
||||
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
|
||||
if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
|
||||
return false;
|
||||
|
||||
if (blurRegion.id === selectedBlurId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= blurRegion.startMs && timeMs <= blurRegion.endMs;
|
||||
});
|
||||
|
||||
const sorted = [
|
||||
...filteredAnnotations.map((annotation) => ({
|
||||
kind: "annotation" as const,
|
||||
region: annotation,
|
||||
})),
|
||||
...filteredBlurRegions.map((blurRegion) => ({
|
||||
kind: "blur" as const,
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
|
||||
// Handle click-through cycling: when clicking same annotation, cycle to next
|
||||
const handleAnnotationClick = (clickedId: string) => {
|
||||
if (!onSelectAnnotation) return;
|
||||
|
||||
// If clicking on already selected annotation and there are multiple overlapping
|
||||
if (clickedId === selectedAnnotationId && sorted.length > 1) {
|
||||
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
|
||||
// Find current index and cycle to next
|
||||
const currentIndex = sorted.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % sorted.length;
|
||||
onSelectAnnotation(sorted[nextIndex].id);
|
||||
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
|
||||
onSelectAnnotation(filteredAnnotations[nextIndex].id);
|
||||
} else {
|
||||
// First click or clicking different annotation
|
||||
onSelectAnnotation(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((annotation) => (
|
||||
const handleBlurClick = (clickedId: string) => {
|
||||
if (!onSelectBlur) return;
|
||||
|
||||
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
|
||||
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
|
||||
onSelectBlur(filteredBlurRegions[nextIndex].id);
|
||||
} else {
|
||||
onSelectBlur(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((item) => (
|
||||
<AnnotationOverlay
|
||||
key={annotation.id}
|
||||
annotation={annotation}
|
||||
isSelected={annotation.id === selectedAnnotationId}
|
||||
containerWidth={overlayRef.current?.clientWidth || 800}
|
||||
containerHeight={overlayRef.current?.clientHeight || 600}
|
||||
onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)}
|
||||
onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
|
||||
onClick={handleAnnotationClick}
|
||||
zIndex={annotation.zIndex}
|
||||
isSelectedBoost={annotation.id === selectedAnnotationId}
|
||||
key={
|
||||
item.kind === "blur"
|
||||
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.shape ?? "rectangle"}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
|
||||
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
|
||||
}
|
||||
annotation={item.region}
|
||||
isSelected={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
containerWidth={overlaySize.width}
|
||||
containerHeight={overlaySize.height}
|
||||
onPositionChange={(id, position) =>
|
||||
item.kind === "blur"
|
||||
? onBlurPositionChange?.(id, position)
|
||||
: onAnnotationPositionChange?.(id, position)
|
||||
}
|
||||
onSizeChange={(id, size) =>
|
||||
item.kind === "blur"
|
||||
? onBlurSizeChange?.(id, size)
|
||||
: onAnnotationSizeChange?.(id, size)
|
||||
}
|
||||
onBlurDataChange={
|
||||
item.kind === "blur"
|
||||
? (id, blurData) => onBlurDataChange?.(id, blurData)
|
||||
: undefined
|
||||
}
|
||||
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
|
||||
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
|
||||
zIndex={item.region.zIndex}
|
||||
isSelectedBoost={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
@@ -17,7 +20,9 @@ import {
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_BLUR_INTENSITY,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
@@ -29,6 +34,7 @@ import {
|
||||
} from "./types";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
|
||||
|
||||
export const WALLPAPER_PATHS = Array.from(
|
||||
{ length: WALLPAPER_COUNT },
|
||||
@@ -254,12 +260,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
const blurShape =
|
||||
typeof region.blurData?.shape === "string" &&
|
||||
VALID_BLUR_SHAPES.has(region.blurData.shape)
|
||||
? region.blurData.shape
|
||||
: DEFAULT_BLUR_DATA.shape;
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
type: region.type === "image" || region.type === "figure" ? region.type : "text",
|
||||
type:
|
||||
region.type === "image" || region.type === "figure" || region.type === "blur"
|
||||
? region.type
|
||||
: "text",
|
||||
content: typeof region.content === "string" ? region.content : "",
|
||||
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
||||
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
||||
@@ -306,6 +320,37 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
...region.figureData,
|
||||
}
|
||||
: undefined,
|
||||
blurData:
|
||||
region.blurData && typeof region.blurData === "object"
|
||||
? {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...region.blurData,
|
||||
shape: blurShape,
|
||||
intensity: isFiniteNumber(region.blurData.intensity)
|
||||
? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
|
||||
: DEFAULT_BLUR_INTENSITY,
|
||||
freehandPoints: Array.isArray(region.blurData.freehandPoints)
|
||||
? region.blurData.freehandPoints
|
||||
.filter(
|
||||
(
|
||||
point,
|
||||
): point is {
|
||||
x: number;
|
||||
y: number;
|
||||
} =>
|
||||
Boolean(
|
||||
point &&
|
||||
isFiniteNumber((point as { x?: unknown }).x) &&
|
||||
isFiniteNumber((point as { y?: unknown }).y),
|
||||
),
|
||||
)
|
||||
.map((point) => ({
|
||||
x: clamp(point.x, 0, 100),
|
||||
y: clamp(point.y, 0, 100),
|
||||
}))
|
||||
: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ItemProps {
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
speedValue?: number;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed";
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
}
|
||||
|
||||
// Map zoom depth to multiplier labels
|
||||
|
||||
@@ -44,6 +44,7 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
|
||||
const ZOOM_ROW_ID = "row-zoom";
|
||||
const TRIM_ROW_ID = "row-trim";
|
||||
const ANNOTATION_ROW_ID = "row-annotation";
|
||||
const BLUR_ROW_ID = "row-blur";
|
||||
const SPEED_ROW_ID = "row-speed";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
@@ -73,6 +74,12 @@ interface TimelineEditorProps {
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
onBlurAdded?: (span: Span) => void;
|
||||
onBlurSpanChange?: (id: string, span: Span) => void;
|
||||
onBlurDelete?: (id: string) => void;
|
||||
selectedBlurId?: string | null;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
speedRegions?: SpeedRegion[];
|
||||
onSpeedAdded?: (span: Span) => void;
|
||||
onSpeedSpanChange?: (id: string, span: Span) => void;
|
||||
@@ -96,7 +103,7 @@ interface TimelineRenderItem {
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
speedValue?: number;
|
||||
variant: "zoom" | "trim" | "annotation" | "speed";
|
||||
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -525,10 +532,12 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
keyframes = [],
|
||||
}: {
|
||||
@@ -540,10 +549,12 @@ function Timeline({
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectTrim?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
selectedZoomId: string | null;
|
||||
selectedTrimId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedBlurId?: string | null;
|
||||
selectedSpeedId?: string | null;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
}) {
|
||||
@@ -568,6 +579,7 @@ function Timeline({
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectBlur?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
@@ -586,6 +598,7 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
videoDurationMs,
|
||||
sidebarWidth,
|
||||
@@ -637,6 +650,7 @@ function Timeline({
|
||||
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
||||
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
||||
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
||||
const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
|
||||
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
|
||||
|
||||
return (
|
||||
@@ -711,6 +725,22 @@ function Timeline({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={BLUR_ROW_ID} isEmpty={blurItems.length === 0} hint={t("hints.pressBlur")}>
|
||||
{blurItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedBlurId}
|
||||
onSelect={() => onSelectBlur?.(item.id)}
|
||||
variant={item.variant}
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
|
||||
{speedItems.map((item) => (
|
||||
<Item
|
||||
@@ -755,6 +785,12 @@ export default function TimelineEditor({
|
||||
onAnnotationDelete,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
blurRegions = [],
|
||||
onBlurAdded,
|
||||
onBlurSpanChange,
|
||||
onBlurDelete,
|
||||
selectedBlurId,
|
||||
onSelectBlur,
|
||||
speedRegions = [],
|
||||
onSpeedAdded,
|
||||
onSpeedSpanChange,
|
||||
@@ -839,6 +875,12 @@ export default function TimelineEditor({
|
||||
onSelectAnnotation(null);
|
||||
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
||||
|
||||
const deleteSelectedBlur = useCallback(() => {
|
||||
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
|
||||
onBlurDelete(selectedBlurId);
|
||||
onSelectBlur(null);
|
||||
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
|
||||
|
||||
const deleteSelectedSpeed = useCallback(() => {
|
||||
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
|
||||
onSpeedDelete(selectedSpeedId);
|
||||
@@ -908,9 +950,10 @@ export default function TimelineEditor({
|
||||
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
|
||||
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
|
||||
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
|
||||
const isBlurItem = blurRegions.some((r) => r.id === excludeId);
|
||||
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
|
||||
|
||||
if (isAnnotationItem) {
|
||||
if (isAnnotationItem || isBlurItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -937,7 +980,7 @@ export default function TimelineEditor({
|
||||
|
||||
return false;
|
||||
},
|
||||
[zoomRegions, trimRegions, annotationRegions, speedRegions],
|
||||
[zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
|
||||
);
|
||||
|
||||
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
|
||||
@@ -1165,6 +1208,21 @@ export default function TimelineEditor({
|
||||
onAnnotationAdded({ start: startPos, end: endPos });
|
||||
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleAddBlur = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
||||
onBlurAdded({ start: startPos, end: endPos });
|
||||
}, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
@@ -1183,6 +1241,9 @@ export default function TimelineEditor({
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
|
||||
handleAddBlur();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
|
||||
handleAddSpeed();
|
||||
}
|
||||
@@ -1223,6 +1284,8 @@ export default function TimelineEditor({
|
||||
deleteSelectedTrim();
|
||||
} else if (selectedAnnotationId) {
|
||||
deleteSelectedAnnotation();
|
||||
} else if (selectedBlurId) {
|
||||
deleteSelectedBlur();
|
||||
} else if (selectedSpeedId) {
|
||||
deleteSelectedSpeed();
|
||||
}
|
||||
@@ -1235,18 +1298,22 @@ export default function TimelineEditor({
|
||||
handleAddZoom,
|
||||
handleAddTrim,
|
||||
handleAddAnnotation,
|
||||
handleAddBlur,
|
||||
handleAddSpeed,
|
||||
deleteSelectedKeyframe,
|
||||
deleteSelectedZoom,
|
||||
deleteSelectedTrim,
|
||||
deleteSelectedAnnotation,
|
||||
deleteSelectedBlur,
|
||||
deleteSelectedSpeed,
|
||||
selectedKeyframeId,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
annotationRegions,
|
||||
blurRegions,
|
||||
currentTime,
|
||||
onSelectAnnotation,
|
||||
keyShortcuts,
|
||||
@@ -1304,6 +1371,14 @@ export default function TimelineEditor({
|
||||
};
|
||||
});
|
||||
|
||||
const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: BLUR_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: t("labels.blurItem", { index: String(index + 1) }),
|
||||
variant: "blur",
|
||||
}));
|
||||
|
||||
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: SPEED_ROW_ID,
|
||||
@@ -1313,8 +1388,8 @@ export default function TimelineEditor({
|
||||
variant: "speed",
|
||||
}));
|
||||
|
||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
|
||||
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
|
||||
|
||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||
const allRegionSpans = useMemo(() => {
|
||||
@@ -1335,6 +1410,8 @@ export default function TimelineEditor({
|
||||
onSpeedSpanChange?.(id, span);
|
||||
} else if (annotationRegions.some((r) => r.id === id)) {
|
||||
onAnnotationSpanChange?.(id, span);
|
||||
} else if (blurRegions.some((r) => r.id === id)) {
|
||||
onBlurSpanChange?.(id, span);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1342,10 +1419,12 @@ export default function TimelineEditor({
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
blurRegions,
|
||||
onZoomSpanChange,
|
||||
onTrimSpanChange,
|
||||
onSpeedSpanChange,
|
||||
onAnnotationSpanChange,
|
||||
onBlurSpanChange,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1403,6 +1482,25 @@ export default function TimelineEditor({
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddBlur}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
|
||||
title={t("buttons.addBlur")}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="8" cy="12" r="3" />
|
||||
<circle cx="16" cy="12" r="3" />
|
||||
<path d="M6 6h12M6 18h12" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddSpeed}
|
||||
variant="ghost"
|
||||
@@ -1489,10 +1587,12 @@ export default function TimelineEditor({
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectTrim={onSelectTrim}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
onSelectBlur={onSelectBlur}
|
||||
onSelectSpeed={onSelectSpeed}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
selectedBlurId={selectedBlurId}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
keyframes={keyframes}
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface TrimRegion {
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export type AnnotationType = "text" | "image" | "figure";
|
||||
export type AnnotationType = "text" | "image" | "figure" | "blur";
|
||||
|
||||
export type ArrowDirection =
|
||||
| "up"
|
||||
@@ -65,6 +65,19 @@ export interface FigureData {
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export type BlurShape = "rectangle" | "oval" | "freehand";
|
||||
|
||||
export const MIN_BLUR_INTENSITY = 2;
|
||||
export const MAX_BLUR_INTENSITY = 40;
|
||||
export const DEFAULT_BLUR_INTENSITY = 12;
|
||||
|
||||
export interface BlurData {
|
||||
shape: BlurShape;
|
||||
intensity: number;
|
||||
// Points are normalized (0-100) within the annotation bounds.
|
||||
freehandPoints?: Array<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -99,6 +112,7 @@ export interface AnnotationRegion {
|
||||
style: AnnotationTextStyle;
|
||||
zIndex: number;
|
||||
figureData?: FigureData;
|
||||
blurData?: BlurData;
|
||||
}
|
||||
|
||||
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
|
||||
@@ -128,6 +142,24 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
|
||||
strokeWidth: 4,
|
||||
};
|
||||
|
||||
export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
|
||||
{ x: 10, y: 30 },
|
||||
{ x: 25, y: 10 },
|
||||
{ x: 55, y: 8 },
|
||||
{ x: 82, y: 20 },
|
||||
{ x: 90, y: 45 },
|
||||
{ x: 78, y: 72 },
|
||||
{ x: 52, y: 90 },
|
||||
{ x: 22, y: 84 },
|
||||
{ x: 8, y: 58 },
|
||||
];
|
||||
|
||||
export const DEFAULT_BLUR_DATA: BlurData = {
|
||||
shape: "rectangle",
|
||||
intensity: DEFAULT_BLUR_INTENSITY,
|
||||
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
};
|
||||
|
||||
export interface CropRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"typeText": "Text",
|
||||
"typeImage": "Image",
|
||||
"typeArrow": "Arrow",
|
||||
"typeBlur": "Blur",
|
||||
"textContent": "Text Content",
|
||||
"textPlaceholder": "Enter your text...",
|
||||
"fontStyle": "Font Style",
|
||||
@@ -117,6 +118,11 @@
|
||||
"arrowDirection": "Arrow Direction",
|
||||
"strokeWidth": "Stroke Width: {{width}}px",
|
||||
"arrowColor": "Arrow Color",
|
||||
"blurShape": "Blur Shape",
|
||||
"blurIntensity": "Blur Intensity",
|
||||
"blurShapeRectangle": "Rectangle",
|
||||
"blurShapeOval": "Oval",
|
||||
"blurShapeFreehand": "Freehand",
|
||||
"deleteAnnotation": "Delete Annotation",
|
||||
"shortcutsAndTips": "Shortcuts & Tips",
|
||||
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Add Trim",
|
||||
"addSpeed": "Add Speed",
|
||||
"addAnnotation": "Add Annotation",
|
||||
"addBlur": "Add Blur",
|
||||
"addKeyframe": "Add Keyframe",
|
||||
"deleteSelected": "Delete Selected",
|
||||
"playPause": "Play / Pause"
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
"suggestZooms": "Suggest Zooms from Cursor",
|
||||
"addTrim": "Add Trim (T)",
|
||||
"addAnnotation": "Add Annotation (A)",
|
||||
"addBlur": "Add Blur (B)",
|
||||
"addSpeed": "Add Speed (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Press Z to add zoom",
|
||||
"pressTrim": "Press T to add trim",
|
||||
"pressAnnotation": "Press A to add annotation",
|
||||
"pressBlur": "Press B to add blur region",
|
||||
"pressSpeed": "Press S to add speed"
|
||||
},
|
||||
"labels": {
|
||||
@@ -19,6 +21,7 @@
|
||||
"trimItem": "Trim {{index}}",
|
||||
"speedItem": "Speed {{index}}",
|
||||
"annotationItem": "Annotation",
|
||||
"blurItem": "Blur {{index}}",
|
||||
"imageItem": "Image",
|
||||
"emptyText": "Empty text"
|
||||
},
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"typeText": "Texto",
|
||||
"typeImage": "Imagen",
|
||||
"typeArrow": "Flecha",
|
||||
"typeBlur": "Desenfoque",
|
||||
"textContent": "Contenido de texto",
|
||||
"textPlaceholder": "Escribe tu texto...",
|
||||
"fontStyle": "Estilo de fuente",
|
||||
@@ -117,6 +118,11 @@
|
||||
"arrowDirection": "Dirección de la flecha",
|
||||
"strokeWidth": "Grosor del trazo: {{width}}px",
|
||||
"arrowColor": "Color de la flecha",
|
||||
"blurShape": "Forma del desenfoque",
|
||||
"blurIntensity": "Intensidad del desenfoque",
|
||||
"blurShapeRectangle": "Rectángulo",
|
||||
"blurShapeOval": "Óvalo",
|
||||
"blurShapeFreehand": "Mano alzada",
|
||||
"deleteAnnotation": "Eliminar anotación",
|
||||
"shortcutsAndTips": "Atajos y consejos",
|
||||
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Agregar recorte",
|
||||
"addSpeed": "Agregar velocidad",
|
||||
"addAnnotation": "Agregar anotación",
|
||||
"addBlur": "Agregar desenfoque",
|
||||
"addKeyframe": "Agregar fotograma clave",
|
||||
"deleteSelected": "Eliminar seleccionado",
|
||||
"playPause": "Reproducir / Pausar"
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
"suggestZooms": "Sugerir zooms desde el cursor",
|
||||
"addTrim": "Agregar recorte (T)",
|
||||
"addAnnotation": "Agregar anotación (A)",
|
||||
"addSpeed": "Agregar velocidad (S)"
|
||||
"addSpeed": "Agregar velocidad (S)",
|
||||
"addBlur": "Agregar desenfoque (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Presiona Z para agregar zoom",
|
||||
"pressTrim": "Presiona T para agregar recorte",
|
||||
"pressAnnotation": "Presiona A para agregar anotación",
|
||||
"pressSpeed": "Presiona S para agregar velocidad"
|
||||
"pressSpeed": "Presiona S para agregar velocidad",
|
||||
"pressBlur": "Presiona B para agregar una región de desenfoque"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Desplazar",
|
||||
@@ -20,7 +22,8 @@
|
||||
"speedItem": "Velocidad {{index}}",
|
||||
"annotationItem": "Anotación",
|
||||
"imageItem": "Imagen",
|
||||
"emptyText": "Texto vacío"
|
||||
"emptyText": "Texto vacío",
|
||||
"blurItem": "Desenfoque {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "No hay video cargado",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"typeText": "Texte",
|
||||
"typeImage": "Image",
|
||||
"typeArrow": "Flèche",
|
||||
"typeBlur": "Flou",
|
||||
"textContent": "Contenu du texte",
|
||||
"textPlaceholder": "Saisissez votre texte...",
|
||||
"fontStyle": "Style de police",
|
||||
@@ -114,6 +115,11 @@
|
||||
"arrowDirection": "Direction de la flèche",
|
||||
"strokeWidth": "Épaisseur du trait : {{width}}px",
|
||||
"arrowColor": "Couleur de la flèche",
|
||||
"blurShape": "Forme du flou",
|
||||
"blurIntensity": "Intensité du flou",
|
||||
"blurShapeRectangle": "Rectangle",
|
||||
"blurShapeOval": "Ovale",
|
||||
"blurShapeFreehand": "Main levée",
|
||||
"deleteAnnotation": "Supprimer l'annotation",
|
||||
"shortcutsAndTips": "Raccourcis & Astuces",
|
||||
"tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Ajouter une coupe",
|
||||
"addSpeed": "Ajouter une vitesse",
|
||||
"addAnnotation": "Ajouter une annotation",
|
||||
"addBlur": "Ajouter un flou",
|
||||
"addKeyframe": "Ajouter une image-clé",
|
||||
"deleteSelected": "Supprimer la sélection",
|
||||
"playPause": "Lecture / Pause"
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
"suggestZooms": "Suggérer des zooms depuis le curseur",
|
||||
"addTrim": "Ajouter une coupe (T)",
|
||||
"addAnnotation": "Ajouter une annotation (A)",
|
||||
"addSpeed": "Ajouter une vitesse (S)"
|
||||
"addSpeed": "Ajouter une vitesse (S)",
|
||||
"addBlur": "Ajouter un flou (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Appuyez sur Z pour ajouter un zoom",
|
||||
"pressTrim": "Appuyez sur T pour ajouter une coupe",
|
||||
"pressAnnotation": "Appuyez sur A pour ajouter une annotation",
|
||||
"pressSpeed": "Appuyez sur S pour ajouter une vitesse"
|
||||
"pressSpeed": "Appuyez sur S pour ajouter une vitesse",
|
||||
"pressBlur": "Appuyez sur B pour ajouter une zone de flou"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Panoramique",
|
||||
@@ -20,7 +22,8 @@
|
||||
"speedItem": "Vitesse {{index}}",
|
||||
"annotationItem": "Annotation",
|
||||
"imageItem": "Image",
|
||||
"emptyText": "Texte vide"
|
||||
"emptyText": "Texte vide",
|
||||
"blurItem": "Flou {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Aucune vidéo chargée",
|
||||
@@ -28,20 +31,20 @@
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Impossible de placer le zoom ici",
|
||||
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
|
||||
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
|
||||
"zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
|
||||
"noCursorTelemetry": "Aucune télémétrie de curseur disponible",
|
||||
"noCursorTelemetryDescription": "Enregistrez d'abord un screencast pour générer des suggestions basées sur le curseur.",
|
||||
"noCursorTelemetryDescription": "Enregistrez d\u0027abord un screencast pour générer des suggestions basées sur le curseur.",
|
||||
"noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
|
||||
"noUsableTelemetryDescription": "L'enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
|
||||
"noUsableTelemetryDescription": "L\u0027enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
|
||||
"noDwellMoments": "Aucun moment de pause du curseur trouvé",
|
||||
"noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
|
||||
"noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible",
|
||||
"noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
|
||||
"cannotPlaceTrim": "Impossible de placer la coupe ici",
|
||||
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
|
||||
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
|
||||
"cannotPlaceSpeed": "Impossible de placer la vitesse ici",
|
||||
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l'espace disponible est insuffisant."
|
||||
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"typeText": "Metin",
|
||||
"typeImage": "Görüntü",
|
||||
"typeArrow": "Ok",
|
||||
"typeBlur": "Bulanık",
|
||||
"textContent": "Metin İçeriği",
|
||||
"textPlaceholder": "Metninizi girin...",
|
||||
"fontStyle": "Yazı Tipi Stili",
|
||||
@@ -114,6 +115,11 @@
|
||||
"arrowDirection": "Ok Yönü",
|
||||
"strokeWidth": "Çizgi Kalınlığı: {{width}}px",
|
||||
"arrowColor": "Ok Rengi",
|
||||
"blurShape": "Bulanık Şekli",
|
||||
"blurIntensity": "Bulanıklık Yoğunluğu",
|
||||
"blurShapeRectangle": "Dikdörtgen",
|
||||
"blurShapeOval": "Oval",
|
||||
"blurShapeFreehand": "Serbest",
|
||||
"deleteAnnotation": "Açıklamayı Sil",
|
||||
"shortcutsAndTips": "Kısayollar ve İpuçları",
|
||||
"tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Kırpma Ekle",
|
||||
"addSpeed": "Hız Ekle",
|
||||
"addAnnotation": "Açıklama Ekle",
|
||||
"addBlur": "Bulanik Ekle",
|
||||
"addKeyframe": "Anahtar Kare Ekle",
|
||||
"deleteSelected": "Seçileni Sil",
|
||||
"playPause": "Oynat / Duraklat"
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
"suggestZooms": "İmleçten Yakınlaştırma Öner",
|
||||
"addTrim": "Kırpma Ekle (T)",
|
||||
"addAnnotation": "Açıklama Ekle (A)",
|
||||
"addSpeed": "Hız Ekle (S)"
|
||||
"addSpeed": "Hız Ekle (S)",
|
||||
"addBlur": "Bulanık ekle (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
|
||||
"pressTrim": "Kırpma eklemek için T tuşuna basın",
|
||||
"pressAnnotation": "Açıklama eklemek için A tuşuna basın",
|
||||
"pressSpeed": "Hız eklemek için S tuşuna basın"
|
||||
"pressSpeed": "Hız eklemek için S tuşuna basın",
|
||||
"pressBlur": "Bulanık bölge eklemek için B tuşuna basın"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Kaydır",
|
||||
@@ -20,7 +22,8 @@
|
||||
"speedItem": "Hız {{index}}",
|
||||
"annotationItem": "Açıklama",
|
||||
"imageItem": "Görüntü",
|
||||
"emptyText": "Boş metin"
|
||||
"emptyText": "Boş metin",
|
||||
"blurItem": "Bulanık {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Video Yüklenmedi",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"typeText": "文本",
|
||||
"typeImage": "图片",
|
||||
"typeArrow": "箭头",
|
||||
"typeBlur": "模糊",
|
||||
"textContent": "文本内容",
|
||||
"textPlaceholder": "输入您的文本...",
|
||||
"fontStyle": "字体样式",
|
||||
@@ -117,6 +118,11 @@
|
||||
"arrowDirection": "箭头方向",
|
||||
"strokeWidth": "描边宽度:{{width}}px",
|
||||
"arrowColor": "箭头颜色",
|
||||
"blurShape": "模糊形状",
|
||||
"blurIntensity": "模糊强度",
|
||||
"blurShapeRectangle": "矩形",
|
||||
"blurShapeOval": "椭圆",
|
||||
"blurShapeFreehand": "自由手绘",
|
||||
"deleteAnnotation": "删除标注",
|
||||
"shortcutsAndTips": "快捷键与提示",
|
||||
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "添加剪辑",
|
||||
"addSpeed": "添加速度",
|
||||
"addAnnotation": "添加标注",
|
||||
"addBlur": "添加模糊",
|
||||
"addKeyframe": "添加关键帧",
|
||||
"deleteSelected": "删除所选",
|
||||
"playPause": "播放 / 暂停"
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
"suggestZooms": "根据光标建议缩放",
|
||||
"addTrim": "添加剪辑 (T)",
|
||||
"addAnnotation": "添加标注 (A)",
|
||||
"addSpeed": "添加速度 (S)"
|
||||
"addSpeed": "添加速度 (S)",
|
||||
"addBlur": "添加模糊 (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "按 Z 添加缩放",
|
||||
"pressTrim": "按 T 添加剪辑",
|
||||
"pressAnnotation": "按 A 添加标注",
|
||||
"pressSpeed": "按 S 添加速度"
|
||||
"pressSpeed": "按 S 添加速度",
|
||||
"pressBlur": "按 B 添加模糊区域"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "平移",
|
||||
@@ -20,7 +22,8 @@
|
||||
"speedItem": "速度 {{index}}",
|
||||
"annotationItem": "标注",
|
||||
"imageItem": "图片",
|
||||
"emptyText": "空文本"
|
||||
"emptyText": "空文本",
|
||||
"blurItem": "模糊 {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "未加载视频",
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { AnnotationRegion, ArrowDirection } from "@/components/video-editor/types";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type ArrowDirection,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MIN_BLUR_INTENSITY,
|
||||
} from "@/components/video-editor/types";
|
||||
|
||||
let blurScratchCanvas: HTMLCanvasElement | null = null;
|
||||
let blurScratchCtx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
// SVG path data for each arrow direction
|
||||
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
|
||||
@@ -96,6 +105,93 @@ function renderArrow(
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawBlurPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const shape = annotation.blurData?.shape || "rectangle";
|
||||
if (shape === "rectangle") {
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shape === "oval") {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const points = annotation.blurData?.freehandPoints;
|
||||
if (shape === "freehand" && points && points.length >= 3) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + (points[0].x / 100) * width, y + (points[0].y / 100) * height);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ctx.lineTo(x + (points[i].x / 100) * width, y + (points[i].y / 100) * height);
|
||||
}
|
||||
ctx.closePath();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, height);
|
||||
}
|
||||
|
||||
function renderBlur(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
scaleFactor: number,
|
||||
) {
|
||||
const canvas = ctx.canvas;
|
||||
const configuredIntensity = annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY;
|
||||
const blurRadius = Math.max(
|
||||
1,
|
||||
Math.round(clamp(configuredIntensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY) * scaleFactor),
|
||||
);
|
||||
|
||||
// Sample pixels around the target shape too; without this padding, small blur regions
|
||||
// lose intensity because the filter has no neighboring pixels to blend with.
|
||||
const samplePadding = Math.max(2, Math.ceil(blurRadius * 2));
|
||||
const sx = Math.max(0, Math.floor(x) - samplePadding);
|
||||
const sy = Math.max(0, Math.floor(y) - samplePadding);
|
||||
const ex = Math.min(canvas.width, Math.ceil(x + width) + samplePadding);
|
||||
const ey = Math.min(canvas.height, Math.ceil(y + height) + samplePadding);
|
||||
const sw = Math.max(0, ex - sx);
|
||||
const sh = Math.max(0, ey - sy);
|
||||
if (sw <= 0 || sh <= 0) return;
|
||||
|
||||
if (!blurScratchCanvas || !blurScratchCtx) {
|
||||
blurScratchCanvas = document.createElement("canvas");
|
||||
blurScratchCtx = blurScratchCanvas.getContext("2d");
|
||||
}
|
||||
if (!blurScratchCanvas || !blurScratchCtx) return;
|
||||
|
||||
blurScratchCanvas.width = sw;
|
||||
blurScratchCanvas.height = sh;
|
||||
blurScratchCtx.clearRect(0, 0, sw, sh);
|
||||
blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||
|
||||
ctx.save();
|
||||
drawBlurPath(ctx, annotation, x, y, width, height);
|
||||
ctx.clip();
|
||||
ctx.filter = `blur(${blurRadius}px)`;
|
||||
ctx.drawImage(blurScratchCanvas, sx, sy);
|
||||
ctx.filter = "none";
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function renderText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
@@ -304,6 +400,10 @@ export async function renderAnnotations(
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "blur":
|
||||
renderBlur(ctx, annotation, x, y, width, height, scaleFactor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export const SHORTCUT_ACTIONS = [
|
||||
"addTrim",
|
||||
"addSpeed",
|
||||
"addAnnotation",
|
||||
"addBlur",
|
||||
"addKeyframe",
|
||||
"deleteSelected",
|
||||
"playPause",
|
||||
@@ -108,6 +109,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
|
||||
addTrim: { key: "t" },
|
||||
addSpeed: { key: "s" },
|
||||
addAnnotation: { key: "a" },
|
||||
addBlur: { key: "b" },
|
||||
addKeyframe: { key: "f" },
|
||||
deleteSelected: { key: "d", ctrl: true },
|
||||
playPause: { key: " " },
|
||||
@@ -118,6 +120,7 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
|
||||
addTrim: "Add Trim",
|
||||
addSpeed: "Add Speed",
|
||||
addAnnotation: "Add Annotation",
|
||||
addBlur: "Add Blur",
|
||||
addKeyframe: "Add Keyframe",
|
||||
deleteSelected: "Delete Selected",
|
||||
playPause: "Play / Pause",
|
||||
@@ -125,9 +128,10 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
|
||||
|
||||
export function matchesShortcut(
|
||||
e: KeyboardEvent,
|
||||
binding: ShortcutBinding,
|
||||
binding: ShortcutBinding | undefined,
|
||||
isMacPlatform: boolean,
|
||||
): boolean {
|
||||
if (!binding) return false;
|
||||
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
|
||||
|
||||
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
|
||||
|
||||
Reference in New Issue
Block a user