mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: make timestamp editable
This commit is contained in:
@@ -20,6 +20,7 @@ import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatTimeCode } from "@/lib/time";
|
import { formatTimeCode } from "@/lib/time";
|
||||||
|
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||||
import { FONT_CLASS_MAP } from "@/lib/font-config";
|
import { FONT_CLASS_MAP } from "@/lib/font-config";
|
||||||
import { BackgroundSettings } from "../background-settings";
|
import { BackgroundSettings } from "../background-settings";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
@@ -562,7 +563,7 @@ function FullscreenToolbar({
|
|||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
getTotalDuration: () => number;
|
getTotalDuration: () => number;
|
||||||
}) {
|
}) {
|
||||||
const { isPlaying } = usePlaybackStore();
|
const { isPlaying, seek } = usePlaybackStore();
|
||||||
const { activeProject } = useProjectStore();
|
const { activeProject } = useProjectStore();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
@@ -622,9 +623,15 @@ function FullscreenToolbar({
|
|||||||
className="flex items-center gap-2 p-1 pt-2 w-full text-white"
|
className="flex items-center gap-2 p-1 pt-2 w-full text-white"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90">
|
<div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90">
|
||||||
<span className="text-primary">
|
<EditableTimecode
|
||||||
{formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)}
|
time={currentTime}
|
||||||
</span>
|
duration={totalDuration}
|
||||||
|
format="HH:MM:SS:FF"
|
||||||
|
fps={activeProject?.fps || 30}
|
||||||
|
onTimeChange={seek}
|
||||||
|
disabled={!hasAnyElements}
|
||||||
|
className="text-white/90 hover:bg-white/10"
|
||||||
|
/>
|
||||||
<span className="opacity-50">/</span>
|
<span className="opacity-50">/</span>
|
||||||
<span>
|
<span>
|
||||||
{formatTimeCode(
|
{formatTimeCode(
|
||||||
@@ -799,7 +806,7 @@ function PreviewToolbar({
|
|||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
getTotalDuration: () => number;
|
getTotalDuration: () => number;
|
||||||
}) {
|
}) {
|
||||||
const { isPlaying } = usePlaybackStore();
|
const { isPlaying, seek } = usePlaybackStore();
|
||||||
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
|
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
|
||||||
const { activeProject } = useProjectStore();
|
const { activeProject } = useProjectStore();
|
||||||
const {
|
const {
|
||||||
@@ -842,17 +849,18 @@ function PreviewToolbar({
|
|||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[0.75rem] text-muted-foreground flex items-center gap-1",
|
"text-[0.75rem] text-muted-foreground flex items-center gap-1 w-[10rem]",
|
||||||
!hasAnyElements && "opacity-50"
|
!hasAnyElements && "opacity-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-primary tabular-nums">
|
<EditableTimecode
|
||||||
{formatTimeCode(
|
time={currentTime}
|
||||||
currentTime,
|
duration={getTotalDuration()}
|
||||||
"HH:MM:SS:FF",
|
format="HH:MM:SS:FF"
|
||||||
activeProject?.fps || 30
|
fps={activeProject?.fps || 30}
|
||||||
)}
|
onTimeChange={seek}
|
||||||
</span>
|
disabled={!hasAnyElements}
|
||||||
|
/>
|
||||||
<span className="opacity-50">/</span>
|
<span className="opacity-50">/</span>
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{formatTimeCode(
|
{formatTimeCode(
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatTimeCode, parseTimeCode } from "@/lib/time";
|
||||||
|
|
||||||
|
interface EditableTimecodeProps {
|
||||||
|
time: number;
|
||||||
|
duration?: number;
|
||||||
|
format?: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF";
|
||||||
|
fps?: number;
|
||||||
|
onTimeChange?: (time: number) => void;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditableTimecode({
|
||||||
|
time,
|
||||||
|
duration,
|
||||||
|
format = "HH:MM:SS:FF",
|
||||||
|
fps = 30,
|
||||||
|
onTimeChange,
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
}: EditableTimecodeProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const enterPressedRef = useRef(false);
|
||||||
|
|
||||||
|
const formattedTime = formatTimeCode(time, format, fps);
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
setIsEditing(true);
|
||||||
|
setInputValue(formattedTime);
|
||||||
|
setHasError(false);
|
||||||
|
enterPressedRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setInputValue("");
|
||||||
|
setHasError(false);
|
||||||
|
enterPressedRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEdit = () => {
|
||||||
|
const parsedTime = parseTimeCode(inputValue, format, fps);
|
||||||
|
|
||||||
|
if (parsedTime === null) {
|
||||||
|
setHasError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp time to valid range
|
||||||
|
const clampedTime = Math.max(
|
||||||
|
0,
|
||||||
|
duration ? Math.min(duration, parsedTime) : parsedTime
|
||||||
|
);
|
||||||
|
|
||||||
|
onTimeChange?.(clampedTime);
|
||||||
|
setIsEditing(false);
|
||||||
|
setInputValue("");
|
||||||
|
setHasError(false);
|
||||||
|
enterPressedRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
enterPressedRef.current = true;
|
||||||
|
applyEdit();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEditing();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
setHasError(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
// Only apply edit if Enter wasn't pressed (to avoid double processing)
|
||||||
|
if (!enterPressedRef.current && isEditing) {
|
||||||
|
applyEdit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Focus input when entering edit mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-mono bg-transparent border-none outline-none",
|
||||||
|
"focus:bg-background focus:border focus:border-primary focus:px-1 focus:rounded",
|
||||||
|
"tabular-nums text-primary",
|
||||||
|
hasError && "text-destructive focus:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ width: `${formattedTime.length + 1}ch` }}
|
||||||
|
placeholder={formattedTime}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onClick={startEditing}
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-mono tabular-nums text-primary cursor-pointer",
|
||||||
|
"hover:bg-muted/50 hover:rounded px-1 -mx-1 transition-colors",
|
||||||
|
disabled && "cursor-default hover:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title={disabled ? undefined : "Click to edit time"}
|
||||||
|
>
|
||||||
|
{formattedTime}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,3 +23,96 @@ export const formatTimeCode = (
|
|||||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
|
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parseTimeCode = (
|
||||||
|
timeCode: string,
|
||||||
|
format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
|
||||||
|
fps = 30
|
||||||
|
): number | null => {
|
||||||
|
if (!timeCode || typeof timeCode !== "string") return null;
|
||||||
|
|
||||||
|
// Remove any extra whitespace
|
||||||
|
const cleanTimeCode = timeCode.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (format) {
|
||||||
|
case "MM:SS": {
|
||||||
|
const parts = cleanTimeCode.split(":");
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const [minutes, seconds] = parts.map((part) => parseInt(part, 10));
|
||||||
|
if (isNaN(minutes) || isNaN(seconds)) return null;
|
||||||
|
if (minutes < 0 || seconds < 0 || seconds >= 60) return null;
|
||||||
|
return minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "HH:MM:SS": {
|
||||||
|
const parts = cleanTimeCode.split(":");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const [hours, minutes, seconds] = parts.map((part) =>
|
||||||
|
parseInt(part, 10)
|
||||||
|
);
|
||||||
|
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return null;
|
||||||
|
if (
|
||||||
|
hours < 0 ||
|
||||||
|
minutes < 0 ||
|
||||||
|
seconds < 0 ||
|
||||||
|
minutes >= 60 ||
|
||||||
|
seconds >= 60
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return hours * 3600 + minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "HH:MM:SS:CS": {
|
||||||
|
const parts = cleanTimeCode.split(":");
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
const [hours, minutes, seconds, centiseconds] = parts.map((part) =>
|
||||||
|
parseInt(part, 10)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
isNaN(hours) ||
|
||||||
|
isNaN(minutes) ||
|
||||||
|
isNaN(seconds) ||
|
||||||
|
isNaN(centiseconds)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
hours < 0 ||
|
||||||
|
minutes < 0 ||
|
||||||
|
seconds < 0 ||
|
||||||
|
centiseconds < 0 ||
|
||||||
|
minutes >= 60 ||
|
||||||
|
seconds >= 60 ||
|
||||||
|
centiseconds >= 100
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "HH:MM:SS:FF": {
|
||||||
|
const parts = cleanTimeCode.split(":");
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
const [hours, minutes, seconds, frames] = parts.map((part) =>
|
||||||
|
parseInt(part, 10)
|
||||||
|
);
|
||||||
|
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds) || isNaN(frames))
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
hours < 0 ||
|
||||||
|
minutes < 0 ||
|
||||||
|
seconds < 0 ||
|
||||||
|
frames < 0 ||
|
||||||
|
minutes >= 60 ||
|
||||||
|
seconds >= 60 ||
|
||||||
|
frames >= fps
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return hours * 3600 + minutes * 60 + seconds + frames / fps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user