"use client"; import { useEffect, useState } from "react"; import type { EditorCore } from "@/core"; import { useEditor } from "@/editor/use-editor"; import type { BookmarkDragState } from "../hooks/use-bookmark-drag"; import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme"; import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout"; import { DEFAULT_FPS } from "@/fps/defaults"; import { snappedSeekTime } from "opencut-wasm"; import { ArrowTurnBackwardIcon, Delete02Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import type { Bookmark } from "@/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 "@/timeline"; import { type MediaTime, mediaTimeFromSeconds, mediaTimeToSeconds, subMediaTime, ZERO_MEDIA_TIME, } from "@/wasm"; 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: MediaTime; }) { const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); const rate = activeProject?.settings.fps ?? DEFAULT_FPS; const snappedTime = (snappedSeekTime({ time, duration, rate }) ?? time) as MediaTime; 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 && dragState.bookmarkTime === bookmark.time; const displayTime = isDragging ? dragState.currentTime : bookmark.time; const time = bookmark.time; const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME; 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: MediaTime; timelineDuration: MediaTime; 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>) => { const updates: Partial> = {}; 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 }); }; const maxDuration = mediaTimeToSeconds({ time: timelineDuration > time ? subMediaTime({ a: timelineDuration, b: time }) : ZERO_MEDIA_TIME, }); const durationSeconds = mediaTimeToSeconds({ time: bookmark.duration ?? ZERO_MEDIA_TIME, }); 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: maxDuration, }); handleUpdate({ duration: mediaTimeFromSeconds({ seconds: value }), }); }} className="h-8 text-sm" containerClassName="w-full" />
); }