diff options
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/src/components/editor/preview-panel.tsx | 34 | ||||
| -rw-r--r-- | apps/web/src/components/ui/editable-timecode.tsx | 137 | ||||
| -rw-r--r-- | apps/web/src/lib/time.ts | 93 |
3 files changed, 251 insertions, 13 deletions
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 5d038d0f..4cc35bb5 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -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( diff --git a/apps/web/src/components/ui/editable-timecode.tsx b/apps/web/src/components/ui/editable-timecode.tsx new file mode 100644 index 00000000..f6d29f49 --- /dev/null +++ b/apps/web/src/components/ui/editable-timecode.tsx @@ -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>
+ );
+}
diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts index df02d837..f24c5961 100644 --- a/apps/web/src/lib/time.ts +++ b/apps/web/src/lib/time.ts @@ -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; +}; |
