diff options
Diffstat (limited to 'apps/web/src/components')
4 files changed, 377 insertions, 385 deletions
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index c346ad53..6a323a1c 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -1,150 +1,149 @@ -"use client";
-
-import { useEffect, useRef, useState } from "react";
-import { formatTimeCode, parseTimeCode } from "@/lib/time";
-import type { TTimeCode } from "@/lib/time";
-import { cn } from "@/utils/ui";
-
-interface EditableTimecodeProps {
- time: number;
- duration: number;
- format?: TTimeCode;
- fps: number;
- onTimeChange?: ({ time }: { time: number }) => void;
- className?: string;
- disabled?: boolean;
-}
-
-export function EditableTimecode({
- time,
- duration,
- format = "HH:MM:SS:FF",
- fps,
- 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({ timeInSeconds: 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({ timeCode: inputValue, format, fps });
-
- if (parsedTime === null) {
- setHasError(true);
- return;
- }
-
- const clampedTime = Math.max(
- 0,
- duration ? Math.min(duration, parsedTime) : parsedTime,
- );
-
- onTimeChange?.({ time: clampedTime });
- setIsEditing(false);
- setInputValue("");
- setHasError(false);
- enterPressedRef.current = false;
- };
-
- const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
- if (event.key === "Enter") {
- event.preventDefault();
- enterPressedRef.current = true;
- applyEdit();
- } else if (event.key === "Escape") {
- event.preventDefault();
- cancelEditing();
- }
- };
-
- const handleInputChange = ({
- target,
- }: React.ChangeEvent<HTMLInputElement>) => {
- setInputValue(target.value);
- setHasError(false);
- };
-
- const handleBlur = () => {
- if (!enterPressedRef.current && isEditing) {
- applyEdit();
- }
- };
-
- const handleDisplayKeyDown = (
- event: React.KeyboardEvent<HTMLButtonElement>,
- ) => {
- if (disabled) return;
-
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- startEditing();
- }
- };
-
- 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(
- "-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
- "focus:bg-background focus:border-primary focus:rounded",
- "text-primary tabular-nums",
- hasError && "text-destructive focus:border-destructive",
- className,
- )}
- style={{ width: `${formattedTime.length + 1}ch` }}
- placeholder={formattedTime}
- />
- );
- }
-
- return (
- <button
- type="button"
- onClick={startEditing}
- onKeyDown={handleDisplayKeyDown}
- disabled={disabled}
- className={cn(
- "text-primary cursor-pointer font-mono text-xs tabular-nums",
- "hover:bg-muted/50 -mx-1 px-1 hover:rounded",
- disabled && "cursor-default hover:bg-transparent",
- className,
- )}
- title={disabled ? undefined : "Click to edit time"}
- >
- {formattedTime}
- </button>
- );
-}
+"use client"; + +import { useEffect, useRef, useState } from "react"; +import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm"; +import { cn } from "@/utils/ui"; + +interface EditableTimecodeProps { + time: number; + duration: number; + format?: TimeCodeFormat; + fps: number; + onTimeChange?: ({ time }: { time: number }) => void; + className?: string; + disabled?: boolean; +} + +export function EditableTimecode({ + time, + duration, + format = "HH:MM:SS:FF", + fps, + 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({ timeInSeconds: 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({ timeCode: inputValue, format, fps }); + + if (parsedTime == null) { + setHasError(true); + return; + } + + const clampedTime = Math.max( + 0, + duration ? Math.min(duration, parsedTime) : parsedTime, + ); + + onTimeChange?.({ time: clampedTime }); + setIsEditing(false); + setInputValue(""); + setHasError(false); + enterPressedRef.current = false; + }; + + const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { + if (event.key === "Enter") { + event.preventDefault(); + enterPressedRef.current = true; + applyEdit(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelEditing(); + } + }; + + const handleInputChange = ({ + target, + }: React.ChangeEvent<HTMLInputElement>) => { + setInputValue(target.value); + setHasError(false); + }; + + const handleBlur = () => { + if (!enterPressedRef.current && isEditing) { + applyEdit(); + } + }; + + const handleDisplayKeyDown = ( + event: React.KeyboardEvent<HTMLButtonElement>, + ) => { + if (disabled) return; + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + startEditing(); + } + }; + + 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( + "-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none", + "focus:bg-background focus:border-primary focus:rounded", + "text-primary tabular-nums", + hasError && "text-destructive focus:border-destructive", + className, + )} + style={{ width: `${formattedTime.length + 1}ch` }} + placeholder={formattedTime} + /> + ); + } + + return ( + <button + type="button" + onClick={startEditing} + onKeyDown={handleDisplayKeyDown} + disabled={disabled} + className={cn( + "text-primary cursor-pointer font-mono text-xs tabular-nums", + "hover:bg-muted/50 -mx-1 px-1 hover:rounded", + disabled && "cursor-default hover:bg-transparent", + className, + )} + title={disabled ? undefined : "Click to edit time"} + > + {formattedTime} + </button> + ); +} diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx index fad711e3..c31c8e96 100644 --- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx @@ -1,83 +1,80 @@ -import {
- Dialog,
- DialogBody,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import type { TProjectMetadata } from "@/lib/project/types";
-import { formatDate } from "@/utils/date";
-import { formatTimeCode } from "@/lib/time";
-import { Button } from "@/components/ui/button";
-
-function InfoRow({
- label,
- value,
-}: {
- label: string;
- value: string | React.ReactNode;
-}) {
- return (
- <div className="flex justify-between items-center py-0 last:pb-0">
- <span className="text-muted-foreground text-sm">{label}</span>
- <span className="text-sm font-medium">{value}</span>
- </div>
- );
-}
-
-export function ProjectInfoDialog({
- isOpen,
- onOpenChange,
- project,
-}: {
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
- project: TProjectMetadata;
-}) {
- const durationFormatted =
- project.duration > 0
- ? formatTimeCode({
- timeInSeconds: project.duration,
- format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
- })
- : "0:00";
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
- <DialogHeader>
- <DialogTitle className="truncate max-w-[350px]">
- {project.name}
- </DialogTitle>
- </DialogHeader>
-
- <DialogBody className="flex flex-col">
- <InfoRow label="Duration" value={durationFormatted} />
- <InfoRow
- label="Created"
- value={formatDate({ date: project.createdAt })}
- />
- <InfoRow
- label="Modified"
- value={formatDate({ date: project.updatedAt })}
- />
- <InfoRow
- label="Project ID"
- value={
- <code className="text-xs bg-muted px-1.5 py-0.5 rounded">
- {project.id.slice(0, 8)}
- </code>
- }
- />
- </DialogBody>
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- Close
- </Button>
- <Button onClick={() => onOpenChange(false)}>Done</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-}
+import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { TProjectMetadata } from "@/lib/project/types"; +import { formatDate } from "@/utils/date"; +import { formatTimeCode } from "opencut-wasm"; +import { Button } from "@/components/ui/button"; + +function InfoRow({ + label, + value, +}: { + label: string; + value: string | React.ReactNode; +}) { + return ( + <div className="flex justify-between items-center py-0 last:pb-0"> + <span className="text-muted-foreground text-sm">{label}</span> + <span className="text-sm font-medium">{value}</span> + </div> + ); +} + +export function ProjectInfoDialog({ + isOpen, + onOpenChange, + project, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + project: TProjectMetadata; +}) { + const durationFormatted = + project.duration > 0 + ? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "") + : "0:00"; + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent onOpenAutoFocus={(event) => event.preventDefault()}> + <DialogHeader> + <DialogTitle className="truncate max-w-[350px]"> + {project.name} + </DialogTitle> + </DialogHeader> + + <DialogBody className="flex flex-col"> + <InfoRow label="Duration" value={durationFormatted} /> + <InfoRow + label="Created" + value={formatDate({ date: project.createdAt })} + /> + <InfoRow + label="Modified" + value={formatDate({ date: project.updatedAt })} + /> + <InfoRow + label="Project ID" + value={ + <code className="text-xs bg-muted px-1.5 py-0.5 rounded"> + {project.id.slice(0, 8)} + </code> + } + /> + </DialogBody> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Close + </Button> + <Button onClick={() => onOpenChange(false)}>Done</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx index 5c183bbb..340058d0 100644 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -1,151 +1,147 @@ -"use client";
-
-import { useState, useEffect } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { formatTimeCode } from "@/lib/time";
-import { invokeAction } from "@/lib/actions";
-import { EditableTimecode } from "@/components/editable-timecode";
-import { Button } from "@/components/ui/button";
-import {
- FullScreenIcon,
- GridTableIcon,
- PauseIcon,
- PlayIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { getGuideById } from "@/lib/guides";
-import { Separator } from "@/components/ui/separator";
-import {
- Select,
- SelectTrigger,
- SelectContent,
- SelectItem,
- SelectSeparator,
-} from "@/components/ui/select";
-import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
-import { usePreviewViewport } from "./preview-viewport";
-import { GridPopover } from "./guide-popover";
-import { usePreviewStore } from "@/stores/preview-store";
-
-export function PreviewToolbar({
- onToggleFullscreen,
-}: {
- onToggleFullscreen: () => void;
-}) {
- const activeGuide = usePreviewStore((state) => state.activeGuide);
- const activeGuideDefinition = getGuideById(activeGuide);
-
- return (
- <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
- <TimecodeDisplay />
- <PlayPauseButton />
- <div className="justify-self-end flex items-center gap-2.5">
- <ZoomSelect />
- <Separator orientation="vertical" className="h-4" />
- <GridPopover>
- <Button
- variant={activeGuideDefinition ? "secondary" : "text"}
- size="icon"
- >
- {activeGuideDefinition ? (
- activeGuideDefinition.renderTriggerIcon()
- ) : (
- <HugeiconsIcon icon={GridTableIcon} />
- )}
- </Button>
- </GridPopover>
- <Button variant="text" onClick={onToggleFullscreen}>
- <HugeiconsIcon icon={FullScreenIcon} />
- </Button>
- </div>
- </div>
- );
-}
-
-function TimecodeDisplay() {
- const editor = useEditor();
- const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
- const fps = useEditor((e) => e.project.getActive().settings.fps);
- const [currentTime, setCurrentTime] = useState(() =>
- editor.playback.getCurrentTime(),
- );
-
- useEffect(() => {
- const handler = (e: Event) =>
- setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
- window.addEventListener("playback-update", handler);
- window.addEventListener("playback-seek", handler);
- return () => {
- window.removeEventListener("playback-update", handler);
- window.removeEventListener("playback-seek", handler);
- };
- }, []);
-
- return (
- <div className="flex items-center">
- <EditableTimecode
- time={currentTime}
- duration={totalDuration}
- format="HH:MM:SS:FF"
- fps={fps}
- onTimeChange={({ time }) => editor.playback.seek({ time })}
- className="text-center"
- />
- <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
- <span className="text-muted-foreground font-mono text-xs">
- {formatTimeCode({
- timeInSeconds: totalDuration,
- format: "HH:MM:SS:FF",
- fps,
- })}
- </span>
- </div>
- );
-}
-
-function ZoomSelect() {
- const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
- usePreviewViewport();
-
- const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
-
- const onValueChange = (value: string) => {
- if (value === "fit") {
- fitToScreen();
- } else {
- setViewportPercent({ percent: Number(value) });
- }
- };
-
- return (
- <Select
- value={isAtFit ? "fit" : String(zoomPercent)}
- onValueChange={onValueChange}
- >
- <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
- <SelectContent>
- <SelectItem value="fit">Fit</SelectItem>
- <SelectSeparator />
- {PREVIEW_ZOOM_PRESETS.map((preset) => (
- <SelectItem key={preset} value={String(preset)}>
- {preset}%
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- );
-}
-
-function PlayPauseButton() {
- const isPlaying = useEditor((e) => e.playback.getIsPlaying());
-
- return (
- <Button
- variant="text"
- size="icon"
- onClick={() => invokeAction("toggle-play")}
- >
- <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
- </Button>
- );
-}
+"use client"; + +import { useState, useEffect } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { formatTimeCode } from "opencut-wasm"; +import { invokeAction } from "@/lib/actions"; +import { EditableTimecode } from "@/components/editable-timecode"; +import { Button } from "@/components/ui/button"; +import { + FullScreenIcon, + GridTableIcon, + PauseIcon, + PlayIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { getGuideById } from "@/lib/guides"; +import { Separator } from "@/components/ui/separator"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectSeparator, +} from "@/components/ui/select"; +import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants"; +import { usePreviewViewport } from "./preview-viewport"; +import { GridPopover } from "./guide-popover"; +import { usePreviewStore } from "@/stores/preview-store"; + +export function PreviewToolbar({ + onToggleFullscreen, +}: { + onToggleFullscreen: () => void; +}) { + const activeGuide = usePreviewStore((state) => state.activeGuide); + const activeGuideDefinition = getGuideById(activeGuide); + + return ( + <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5"> + <TimecodeDisplay /> + <PlayPauseButton /> + <div className="justify-self-end flex items-center gap-2.5"> + <ZoomSelect /> + <Separator orientation="vertical" className="h-4" /> + <GridPopover> + <Button + variant={activeGuideDefinition ? "secondary" : "text"} + size="icon" + > + {activeGuideDefinition ? ( + activeGuideDefinition.renderTriggerIcon() + ) : ( + <HugeiconsIcon icon={GridTableIcon} /> + )} + </Button> + </GridPopover> + <Button variant="text" onClick={onToggleFullscreen}> + <HugeiconsIcon icon={FullScreenIcon} /> + </Button> + </div> + </div> + ); +} + +function TimecodeDisplay() { + const editor = useEditor(); + const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); + const fps = useEditor((e) => e.project.getActive().settings.fps); + const [currentTime, setCurrentTime] = useState(() => + editor.playback.getCurrentTime(), + ); + + useEffect(() => { + const handler = (e: Event) => + setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); + window.addEventListener("playback-update", handler); + window.addEventListener("playback-seek", handler); + return () => { + window.removeEventListener("playback-update", handler); + window.removeEventListener("playback-seek", handler); + }; + }, []); + + return ( + <div className="flex items-center"> + <EditableTimecode + time={currentTime} + duration={totalDuration} + format="HH:MM:SS:FF" + fps={fps} + onTimeChange={({ time }) => editor.playback.seek({ time })} + className="text-center" + /> + <span className="text-muted-foreground px-2 font-mono text-xs">/</span> + <span className="text-muted-foreground font-mono text-xs"> + {formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })} + </span> + </div> + ); +} + +function ZoomSelect() { + const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } = + usePreviewViewport(); + + const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`; + + const onValueChange = (value: string) => { + if (value === "fit") { + fitToScreen(); + } else { + setViewportPercent({ percent: Number(value) }); + } + }; + + return ( + <Select + value={isAtFit ? "fit" : String(zoomPercent)} + onValueChange={onValueChange} + > + <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger> + <SelectContent> + <SelectItem value="fit">Fit</SelectItem> + <SelectSeparator /> + {PREVIEW_ZOOM_PRESETS.map((preset) => ( + <SelectItem key={preset} value={String(preset)}> + {preset}% + </SelectItem> + ))} + </SelectContent> + </Select> + ); +} + +function PlayPauseButton() { + const isPlaying = useEditor((e) => e.playback.getIsPlaying()); + + return ( + <Button + variant="text" + size="icon" + onClick={() => invokeAction("toggle-play")} + > + <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> + </Button> + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index b47f9d46..e2dc09f1 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -10,7 +10,7 @@ import { TIMELINE_BOOKMARK_ROW_HEIGHT, } from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; -import { getSnappedSeekTime } from "@/lib/time"; +import { getSnappedSeekTime } from "opencut-wasm"; import { ArrowTurnBackwardIcon, Delete02Icon, |
