OpenCut/apps/web/src/components/editor/panels/timeline/bookmarks.tsx

429 lines
12 KiB
TypeScript

"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 (
<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 &&
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 (
<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>
</>
);
}