summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-10 16:28:21 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-10 16:28:21 +0100
commitfca99d6126c31fbb18ed9f1034cee6f940b040e8 (patch)
tree11500ff0fcc4a933650e3e91f1ea00fd315c4d0c /apps/web/src/components/editor/panels/timeline/timeline-element.tsx
parent33a8705f0d967891402c61dd3192e6bad24921ef (diff)
downloadopencut-fca99d6126c31fbb18ed9f1034cee6f940b040e8.tar.gz
refactor
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/timeline-element.tsx')
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx566
1 files changed, 566 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
new file mode 100644
index 00000000..59ce88da
--- /dev/null
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -0,0 +1,566 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import AudioWaveform from "./audio-waveform";
+import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ getTrackClasses,
+ getTrackHeight,
+ canElementHaveAudio,
+ canElementBeHidden,
+ hasMediaId,
+} from "@/lib/timeline";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "../../../ui/context-menu";
+import type {
+ TimelineElement as TimelineElementType,
+ TimelineTrack,
+ ElementDragState,
+} from "@/types/timeline";
+import type { MediaAsset } from "@/types/assets";
+import { mediaSupportsAudio } from "@/lib/media/media-utils";
+import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import Image from "next/image";
+import {
+ ScissorIcon,
+ Delete02Icon,
+ Copy01Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+ VolumeMute02Icon,
+ Search01Icon,
+ Exchange01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { uppercase } from "@/utils/string";
+import type { ComponentProps } from "react";
+
+function getDisplayShortcut(action: TAction) {
+ const { defaultShortcuts } = getActionDefinition(action);
+ if (!defaultShortcuts?.length) {
+ return "";
+ }
+
+ return uppercase({
+ string: defaultShortcuts[0].replace("+", " "),
+ });
+}
+
+interface TimelineElementProps {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ zoomLevel: number;
+ isSelected: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (
+ e: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ dragState: ElementDragState;
+}
+
+export function TimelineElement({
+ element,
+ track,
+ zoomLevel,
+ isSelected,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ dragState,
+}: TimelineElementProps) {
+ const editor = useEditor();
+ const { selectedElements } = useElementSelection();
+ const { requestRevealMedia } = useAssetsPanelStore();
+
+ const mediaAssets = editor.media.getAssets();
+ let mediaAsset: MediaAsset | null = null;
+
+ if (hasMediaId(element)) {
+ mediaAsset =
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ }
+
+ const hasAudio = mediaSupportsAudio({ media: mediaAsset });
+
+ const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
+ useTimelineElementResize({
+ element,
+ track,
+ zoomLevel,
+ onSnapPointChange,
+ onResizeStateChange,
+ });
+
+ const isCurrentElementSelected = selectedElements.some(
+ (selected) =>
+ selected.elementId === element.id && selected.trackId === track.id,
+ );
+
+ const isBeingDragged = dragState.elementId === element.id;
+ const dragOffsetY =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentMouseY - dragState.startMouseY
+ : 0;
+ const elementStartTime =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentTime
+ : element.startTime;
+ const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
+ const displayedDuration = isResizing ? currentDuration : element.duration;
+ const elementWidth =
+ displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const elementLeft = displayedStartTime * 50 * zoomLevel;
+
+ const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
+ event.stopPropagation();
+ if (hasMediaId(element)) {
+ requestRevealMedia(element.mediaId);
+ }
+ };
+
+ const isMuted = canElementHaveAudio(element) && element.muted === true;
+
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ className={`absolute top-0 h-full select-none ${
+ isBeingDragged ? "z-30" : "z-10"
+ }`}
+ style={{
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
+ transform:
+ isBeingDragged && dragState.isDragging
+ ? `translate3d(0, ${dragOffsetY}px, 0)`
+ : undefined,
+ }}
+ >
+ <ElementInner
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ isBeingDragged={isBeingDragged}
+ hasAudio={hasAudio}
+ isMuted={isMuted}
+ mediaAssets={mediaAssets}
+ onElementClick={onElementClick}
+ onElementMouseDown={onElementMouseDown}
+ handleResizeStart={handleResizeStart}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="z-200 w-64">
+ <ActionMenuItem
+ action="split"
+ icon={<HugeiconsIcon icon={ScissorIcon} />}
+ >
+ Split
+ </ActionMenuItem>
+ <CopyMenuItem />
+ {canElementHaveAudio(element) && hasAudio && (
+ <MuteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ isMuted={isMuted}
+ />
+ )}
+ {canElementBeHidden(element) && (
+ <VisibilityMenuItem
+ element={element}
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ />
+ )}
+ {selectedElements.length === 1 && (
+ <ActionMenuItem
+ action="duplicate-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Duplicate
+ </ActionMenuItem>
+ )}
+ {selectedElements.length === 1 && hasMediaId(element) && (
+ <>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Search01Icon} />}
+ onClick={(event) => handleRevealInMedia({ event })}
+ >
+ Reveal media
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Exchange01Icon} />}
+ disabled
+ >
+ Replace media
+ </ContextMenuItem>
+ </>
+ )}
+ <ContextMenuSeparator />
+ <DeleteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ elementType={element.type}
+ selectedCount={selectedElements.length}
+ />
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+}
+
+function ElementInner({
+ element,
+ track,
+ isSelected,
+ isBeingDragged,
+ hasAudio,
+ isMuted,
+ mediaAssets,
+ onElementClick,
+ onElementMouseDown,
+ handleResizeStart,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ isBeingDragged: boolean;
+ hasAudio: boolean;
+ isMuted: boolean;
+ mediaAssets: MediaAsset[];
+ onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ onElementMouseDown: (
+ e: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ handleResizeStart: (params: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ return (
+ <div
+ className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
+ {
+ type: track.type,
+ },
+ )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
+ >
+ <button
+ type="button"
+ className="absolute inset-0 size-full cursor-pointer"
+ onClick={(e) => onElementClick(e, element)}
+ onMouseDown={(e) => onElementMouseDown(e, element)}
+ >
+ <div className="absolute inset-0 flex h-full items-center">
+ <ElementContent
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ mediaAssets={mediaAssets}
+ />
+ </div>
+
+ {(hasAudio
+ ? isMuted
+ : canElementBeHidden(element) && element.hidden) && (
+ <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
+ {hasAudio ? (
+ <HugeiconsIcon
+ icon={VolumeHighIcon}
+ className="size-6 text-white"
+ />
+ ) : (
+ <HugeiconsIcon
+ icon={VolumeOffIcon}
+ className="size-6 text-white"
+ />
+ )}
+ </div>
+ )}
+ </button>
+
+ {isSelected && (
+ <>
+ <ResizeHandle
+ side="left"
+ elementId={element.id}
+ handleResizeStart={handleResizeStart}
+ />
+ <ResizeHandle
+ side="right"
+ elementId={element.id}
+ handleResizeStart={handleResizeStart}
+ />
+ </>
+ )}
+ </div>
+ );
+}
+
+function ResizeHandle({
+ side,
+ elementId,
+ handleResizeStart,
+}: {
+ side: "left" | "right";
+ elementId: string;
+ handleResizeStart: (params: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ const isLeft = side === "left";
+ return (
+ <button
+ type="button"
+ className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
+ onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
+ aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
+ >
+ <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
+ </button>
+ );
+}
+
+function ElementContent({
+ element,
+ track,
+ isSelected,
+ mediaAssets,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ mediaAssets: MediaAsset[];
+}) {
+ if (element.type === "text") {
+ return (
+ <div className="flex size-full items-center justify-start pl-2">
+ <span className="truncate text-xs text-white">{element.content}</span>
+ </div>
+ );
+ }
+
+ if (element.type === "sticker") {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
+ alt={element.name}
+ className="size-5 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+ }
+
+ if (element.type === "audio") {
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+
+ const audioUrl =
+ element.sourceType === "library"
+ ? element.sourceUrl
+ : mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
+
+ if (audioBuffer || audioUrl) {
+ return (
+ <div className="flex size-full items-center gap-2">
+ <div className="min-w-0 flex-1">
+ <AudioWaveform
+ audioBuffer={audioBuffer}
+ audioUrl={audioUrl}
+ height={24}
+ className="w-full"
+ />
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
+
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ if (!mediaAsset) {
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
+
+ if (
+ mediaAsset.type === "image" ||
+ (mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
+ ) {
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * (16 / 9);
+ const imageUrl =
+ mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
+
+ return (
+ <div className="flex size-full items-center justify-center">
+ <div
+ className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
+ >
+ <div
+ className="absolute right-0 left-0"
+ style={{
+ backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ top: isSelected ? "0.25rem" : "0rem",
+ bottom: isSelected ? "0.25rem" : "0rem",
+ }}
+ />
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <span className="text-foreground/80 truncate text-xs">{element.name}</span>
+ );
+}
+
+function CopyMenuItem() {
+ return (
+ <ActionMenuItem
+ action="copy-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Copy
+ </ActionMenuItem>
+ );
+}
+
+function MuteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ isMuted,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ isMuted: boolean;
+}) {
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={VolumeMute02Icon} />;
+ }
+ return isMuted ? (
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ ) : (
+ <HugeiconsIcon icon={VolumeOffIcon} />
+ );
+ };
+
+ return (
+ <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
+ {isMuted ? "Unmute" : "Mute"}
+ </ActionMenuItem>
+ );
+}
+
+function VisibilityMenuItem({
+ element,
+ isMultipleSelected,
+ isCurrentElementSelected,
+}: {
+ element: TimelineElementType;
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+}) {
+ const isHidden = canElementBeHidden(element) && element.hidden;
+
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={ViewOffSlashIcon} />;
+ }
+ return isHidden ? (
+ <HugeiconsIcon icon={ViewIcon} />
+ ) : (
+ <HugeiconsIcon icon={ViewOffSlashIcon} />
+ );
+ };
+
+ return (
+ <ActionMenuItem
+ action="toggle-elements-visibility-selected"
+ icon={getIcon()}
+ >
+ {isHidden ? "Show" : "Hide"}
+ </ActionMenuItem>
+ );
+}
+
+function DeleteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ elementType,
+ selectedCount,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ elementType: TimelineElementType["type"];
+ selectedCount: number;
+}) {
+ return (
+ <ActionMenuItem
+ action="delete-selected"
+ variant="destructive"
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ >
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedCount} elements`
+ : `Delete ${elementType === "text" ? "text" : "clip"}`}
+ </ActionMenuItem>
+ );
+}
+
+function ActionMenuItem({
+ action,
+ children,
+ ...props
+}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
+ action: TAction;
+}) {
+ return (
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ invokeAction(action);
+ }}
+ textRight={getDisplayShortcut(action)}
+ {...props}
+ >
+ {children}
+ </ContextMenuItem>
+ );
+}