diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/bookmarks/components | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/bookmarks/components')
| -rw-r--r-- | apps/web/src/timeline/bookmarks/components/bookmarks.tsx | 425 |
1 files changed, 425 insertions, 0 deletions
diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx new file mode 100644 index 00000000..20e22c57 --- /dev/null +++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx @@ -0,0 +1,425 @@ +"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"; + +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 rate = activeProject?.settings.fps ?? DEFAULT_FPS; + const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; + 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 ( + <div + className="relative flex-1 overflow-hidden" + style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }} + > + <button + className="relative w-full cursor-default select-none border-0 bg-transparent p-0" + style={{ + height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX, + width: `${dynamicTimelineWidth}px`, + }} + aria-label="Timeline ruler" + type="button" + onWheel={handleWheel} + onClick={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; + handleTimelineContentClick(event); + }} + onMouseDown={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; + handleRulerMouseDown(event); + handleRulerTrackingMouseDown(event); + }} + > + {bookmarks.map((bookmark) => ( + <TimelineBookmark + key={`bookmark-${bookmark.time}`} + bookmark={bookmark} + zoomLevel={zoomLevel} + dragState={dragState} + onBookmarkMouseDown={onBookmarkMouseDown} + /> + ))} + </button> + </div> + ); +} + +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 ?? 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 ( + <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> + <PopoverAnchor asChild> + <button + className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0" + style={{ + left: `${bookmarkLeft}px`, + width: `${bookmarkWidth}px`, + }} + aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`} + type="button" + onMouseDown={handleMouseDown} + onClick={handleClick} + onKeyDown={handleKeyDown} + > + {hasDurationRange ? ( + <div + className="absolute opacity-30" + style={{ + top: 1.5, + height: BOOKMARK_MARKER_HEIGHT_PX - 2.5, + left: BOOKMARK_HALF_WIDTH_PX, + width: durationWidth, + backgroundColor: iconColor, + }} + /> + ) : null} + <div + className="absolute top-0 overflow-hidden" + style={{ + left: 0, + width: BOOKMARK_HALF_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + }} + > + <div + className="absolute inset-0" + style={{ + width: BOOKMARK_MARKER_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: "hsl(var(--background))", + }} + /> + <div + className="absolute" + style={{ + top: 1, + left: 1, + width: BOOKMARK_MARKER_WIDTH_PX - 2, + height: BOOKMARK_MARKER_HEIGHT_PX - 2, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: iconColor, + }} + /> + </div> + <div + className="absolute top-0 overflow-hidden" + style={{ + left: rightHalfLeft, + width: BOOKMARK_HALF_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + }} + > + <div + className="absolute top-0" + style={{ + left: -BOOKMARK_HALF_WIDTH_PX, + width: BOOKMARK_MARKER_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: "hsl(var(--background))", + }} + /> + <div + className="absolute" + style={{ + top: 1, + left: 1 - BOOKMARK_HALF_WIDTH_PX, + width: BOOKMARK_MARKER_WIDTH_PX - 2, + height: BOOKMARK_MARKER_HEIGHT_PX - 2, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: iconColor, + }} + /> + </div> + </button> + </PopoverAnchor> + <PopoverContent + className="w-64 flex flex-col gap-3 p-3" + align="start" + side="bottom" + sideOffset={8} + onOpenAutoFocus={(event) => event.preventDefault()} + > + <BookmarkPopoverContent + bookmark={bookmark} + time={time} + timelineDuration={duration} + onPopoverClose={() => setIsPopoverOpen(false)} + /> + </PopoverContent> + </Popover> + ); +} + +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 ( + <> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Note</Label> + <Input + placeholder="Add a note..." + value={bookmark.note ?? ""} + onChange={(event) => handleUpdate({ note: event.target.value })} + className="h-8 text-sm" + /> + </div> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Color</Label> + <div className="relative"> + <ColorPicker + value={uppercase({ string: draftColorHex })} + onChange={(color) => 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() && ( + <Button + type="button" + variant="text" + size="text" + aria-label="Reset to default color" + className="absolute top-1/2 right-1 -translate-y-1/2 mr-1" + onClick={() => + editor.scenes.updateBookmark({ + time, + updates: { color: undefined }, + }) + } + > + <HugeiconsIcon + icon={ArrowTurnBackwardIcon} + className="!size-3.5" + /> + </Button> + )} + </div> + </div> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Duration</Label> + <div className="flex items-center gap-1.5"> + <Input + type="number" + min={0} + step={0.1} + value={bookmark.duration ?? 0} + onChange={(event) => { + 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" + /> + </div> + </div> + <Button + type="button" + variant="outline" + size="sm" + className="text-destructive hover:bg-destructive/10" + onClick={handleRemove} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + handleRemove(); + } + }} + aria-label="delete bookmark" + > + <HugeiconsIcon icon={Delete02Icon} className="!size-3.5" /> + Delete + </Button> + </> + ); +} |
