feat: make timestamp editable

This commit is contained in:
Maze Winther 2025-07-30 17:44:19 +02:00
parent b9e5bdff95
commit f15508df62
3 changed files with 251 additions and 13 deletions

View File

@ -20,6 +20,7 @@ import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode";
import { FONT_CLASS_MAP } from "@/lib/font-config";
import { BackgroundSettings } from "../background-settings";
import { useProjectStore } from "@/stores/project-store";
@ -562,7 +563,7 @@ function FullscreenToolbar({
toggle: () => void;
getTotalDuration: () => number;
}) {
const { isPlaying } = usePlaybackStore();
const { isPlaying, seek } = usePlaybackStore();
const { activeProject } = useProjectStore();
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"
>
<div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90">
<span className="text-primary">
{formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)}
</span>
<EditableTimecode
time={currentTime}
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>
{formatTimeCode(
@ -799,7 +806,7 @@ function PreviewToolbar({
toggle: () => void;
getTotalDuration: () => number;
}) {
const { isPlaying } = usePlaybackStore();
const { isPlaying, seek } = usePlaybackStore();
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
const { activeProject } = useProjectStore();
const {
@ -842,17 +849,18 @@ function PreviewToolbar({
<div>
<p
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"
)}
>
<span className="text-primary tabular-nums">
{formatTimeCode(
currentTime,
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
<EditableTimecode
time={currentTime}
duration={getTotalDuration()}
format="HH:MM:SS:FF"
fps={activeProject?.fps || 30}
onTimeChange={seek}
disabled={!hasAnyElements}
/>
<span className="opacity-50">/</span>
<span className="tabular-nums">
{formatTimeCode(

View File

@ -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>
);
}

View File

@ -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")}`;
}
};
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;
};