"use client"; import { useEffect, useState } from "react"; import type { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; import { DEFAULT_TIMELINE_BOOKMARK_COLOR, } from "./theme"; import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout"; import { DEFAULT_FPS } from "@/lib/fps/constants"; import { getSnappedSeekTime } from "opencut-wasm"; import { ArrowTurnBackwardIcon, Delete02Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import type { Bookmark } from "@/lib/timeline"; import { Popover, PopoverAnchor, PopoverContent, } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; import { ColorPicker } from "@/components/ui/color-picker"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp, formatNumberForDisplay } from "@/utils/math"; import { timelineTimeToPixels, timelineTimeToSnappedPixels, } from "@/lib/timeline"; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; const BOOKMARK_MARKER_HEIGHT_PX = 15; const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2; const BOOKMARK_MARKER_CLIP_PATH = "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)"; function seekToBookmarkTime({ editor, time, }: { editor: EditorCore; time: number; }) { const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); const fps = activeProject?.settings.fps ?? DEFAULT_FPS; const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps }); editor.playback.seek({ time: snappedTime }); } interface TimelineBookmarksRowProps { zoomLevel: number; dynamicTimelineWidth: number; dragState: BookmarkDragState; onBookmarkMouseDown: (params: { event: React.MouseEvent; bookmark: Bookmark; }) => void; handleWheel: (event: React.WheelEvent) => void; handleTimelineContentClick: (event: React.MouseEvent) => void; handleRulerTrackingMouseDown: (event: React.MouseEvent) => void; handleRulerMouseDown: (event: React.MouseEvent) => void; } export function TimelineBookmarksRow({ zoomLevel, dynamicTimelineWidth, dragState, onBookmarkMouseDown, handleWheel, handleTimelineContentClick, handleRulerTrackingMouseDown, handleRulerMouseDown, }: TimelineBookmarksRowProps) { const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks); return (
); } function TimelineBookmark({ bookmark, zoomLevel, dragState, onBookmarkMouseDown, }: { bookmark: Bookmark; zoomLevel: number; dragState: BookmarkDragState; onBookmarkMouseDown: (params: { event: React.MouseEvent; bookmark: Bookmark; }) => void; }) { const editor = useEditor(); const duration = editor.timeline.getTotalDuration(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const isDragging = dragState.isDragging && dragState.bookmarkTime !== null && Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON; const displayTime = isDragging ? dragState.currentTime : bookmark.time; const time = bookmark.time; const bookmarkDuration = bookmark.duration ?? 0; const durationWidth = bookmarkDuration > 0 ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) : 0; const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX; const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0); const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel }); const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR; const handleSeek = () => seekToBookmarkTime({ editor, time }); const handleClick = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); if (event.detail === 2) { setIsPopoverOpen(true); } else { handleSeek(); } }; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); handleSeek(); }; const handleMouseDown = (event: React.MouseEvent) => { onBookmarkMouseDown({ event, bookmark }); event.preventDefault(); event.stopPropagation(); }; return ( event.preventDefault()} > setIsPopoverOpen(false)} /> ); } function BookmarkPopoverContent({ bookmark, time, timelineDuration, onPopoverClose, }: { bookmark: Bookmark; time: number; timelineDuration: number; onPopoverClose: () => void; }) { const editor = useEditor(); const [draftColorHex, setDraftColorHex] = useState( (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR) .replace("#", "") .toUpperCase(), ); useEffect(() => { setDraftColorHex( (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR) .replace("#", "") .toUpperCase(), ); }, [bookmark.color]); const handleRemove = () => { editor.scenes.removeBookmark({ time }); onPopoverClose(); }; const handleUpdate = ({ note, color, duration, }: Partial<{ note: string; color: string; duration: number }>) => { const updates: Partial<{ note: string; color: string; duration: number }> = {}; if (note !== undefined && note !== bookmark.note) updates.note = note; if ( color !== undefined && color.toUpperCase() !== (bookmark.color ?? "").toUpperCase() ) { updates.color = color; } if (duration !== undefined && duration !== bookmark.duration) { updates.duration = duration; } if (Object.keys(updates).length === 0) return; editor.scenes.updateBookmark({ time, updates }); }; return ( <>
handleUpdate({ note: event.target.value })} className="h-8 text-sm" />
setDraftColorHex(uppercase({ string: color }))} onChangeEnd={(color) => handleUpdate({ color: `#${uppercase({ string: color })}` }) } className="bg-background border" /> {bookmark.color && bookmark.color.replace(/^#/, "").toUpperCase() !== DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && ( )}
{ const parsed = parseFloat(event.target.value); const value = Number.isNaN(parsed) ? 0 : clamp({ value: parsed, min: 0, max: Math.max(0, timelineDuration - time), }); handleUpdate({ duration: value }); }} className="h-8 text-sm" containerClassName="w-full" />
); }