summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/bookmarks/components
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/bookmarks/components
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-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.tsx425
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>
+ </>
+ );
+}