mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
holy cow
This commit is contained in:
@@ -55,7 +55,7 @@ export function TabBar() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-[100] flex cursor-pointer flex-col items-center gap-0.5",
|
||||
"flex cursor-pointer flex-col items-center gap-0.5",
|
||||
activeTab === tabKey
|
||||
? "text-primary !opacity-100"
|
||||
: "text-muted-foreground",
|
||||
@@ -73,7 +73,7 @@ export function TabBar() {
|
||||
variant="sidebar"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="dark:text-base-gray-950 text-sm font-medium leading-none text-black dark:text-white">
|
||||
<div className="text-foreground text-sm font-medium leading-none">
|
||||
{tab.label}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@@ -99,7 +99,7 @@ function FadeOverlay({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-0 right-0 z-[101] h-6 transition-opacity duration-200",
|
||||
"pointer-events-none absolute left-0 right-0 h-6 transition-opacity duration-200",
|
||||
direction === "top" && show
|
||||
? "from-panel top-0 bg-gradient-to-b to-transparent"
|
||||
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
|
||||
|
||||
@@ -95,8 +95,6 @@ export function Captions() {
|
||||
|
||||
const { text, segments } = await transcriptionResponse.json();
|
||||
|
||||
console.log("Transcription completed:", { text, segments });
|
||||
|
||||
const shortCaptions: Array<{
|
||||
text: string;
|
||||
startTime: number;
|
||||
@@ -132,7 +130,10 @@ export function Captions() {
|
||||
});
|
||||
});
|
||||
|
||||
const captionTrackId = editor.timeline.addTrack({ type: "text", index: 0 });
|
||||
const captionTrackId = editor.timeline.addTrack({
|
||||
type: "text",
|
||||
index: 0,
|
||||
});
|
||||
|
||||
shortCaptions.forEach((caption, index) => {
|
||||
editor.timeline.addElementToTrack({
|
||||
@@ -148,14 +149,10 @@ export function Captions() {
|
||||
} as TextElement,
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ ${shortCaptions.length} short-form caption chunks added to timeline!`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Transcription failed:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
error instanceof Error ? error.message : "An unexpected error occurred",
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
@@ -164,7 +161,10 @@ export function Captions() {
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseView ref={containerRef} className="flex flex-col justify-between h-full">
|
||||
<BaseView
|
||||
ref={containerRef}
|
||||
className="flex h-full flex-col justify-between"
|
||||
>
|
||||
<PropertyGroup title="Language">
|
||||
<LanguageSelect
|
||||
selectedCountry={selectedCountry}
|
||||
@@ -176,8 +176,8 @@ export function Captions() {
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -192,7 +192,7 @@ export function Captions() {
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing && <Loader2 className="mr-1 size-4 animate-spin" />}
|
||||
{isProcessing && <Loader2 className="mr-1 animate-spin" />}
|
||||
{isProcessing ? processingStep : "Generate transcript"}
|
||||
</Button>
|
||||
|
||||
@@ -243,7 +243,7 @@ export function Captions() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<strong>True zero-knowledge privacy:</strong> Encryption keys
|
||||
are generated randomly in your browser and never stored
|
||||
anywhere. It's cryptographically impossible for us, our cloud
|
||||
|
||||
@@ -231,9 +231,9 @@ export function MediaView() {
|
||||
className="w-full"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<CloudUpload className="size-4" />
|
||||
<CloudUpload />
|
||||
)}
|
||||
<span>Upload</span>
|
||||
</Button>
|
||||
|
||||
@@ -239,7 +239,7 @@ function SoundEffectsView() {
|
||||
size="icon"
|
||||
className={cn(showCommercialOnly && "text-primary")}
|
||||
>
|
||||
<ListFilter className="w-4 h-4" />
|
||||
<ListFilter />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
@@ -521,9 +521,9 @@ function AudioItem({
|
||||
<div className="relative w-12 h-12 bg-accent rounded-md flex items-center justify-center overflow-hidden shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent" />
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-5 h-5" />
|
||||
<PauseIcon className="size-5" />
|
||||
) : (
|
||||
<PlayIcon className="w-5 h-5" />
|
||||
<PlayIcon className="size-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -542,7 +542,7 @@ function AudioItem({
|
||||
onClick={handleAddToTimeline}
|
||||
title="Add to timeline"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
@@ -555,7 +555,7 @@ function AudioItem({
|
||||
onClick={handleSaveClick}
|
||||
title={isSaved ? "Remove from saved" : "Save sound"}
|
||||
>
|
||||
<HeartIcon className={`w-4 h-4 ${isSaved ? "fill-current" : ""}`} />
|
||||
<HeartIcon className={`${isSaved ? "fill-current" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,13 @@ export function MigrationDialog() {
|
||||
|
||||
if (!migrationState.isMigrating) return null;
|
||||
|
||||
const title = migrationState.projectName
|
||||
? "Updating project"
|
||||
: "Updating projects";
|
||||
const description = migrationState.projectName
|
||||
? `Upgrading "${migrationState.projectName}" from v${migrationState.fromVersion} to v${migrationState.toVersion}`
|
||||
: `Upgrading projects from v${migrationState.fromVersion} to v${migrationState.toVersion}`;
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
@@ -24,11 +31,8 @@ export function MigrationDialog() {
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Updating project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upgrading "{migrationState.projectName}" from v
|
||||
{migrationState.fromVersion} to v{migrationState.toVersion}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-center py-4">
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { canDeleteScene } from "@/lib/scene-utils";
|
||||
import { canDeleteScene, getMainScene } from "@/lib/scene-utils";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
@@ -85,6 +85,11 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
||||
setIsSelectMode(false);
|
||||
};
|
||||
|
||||
const isMainSceneSelected = (() => {
|
||||
const mainScene = getMainScene({ scenes });
|
||||
return Boolean(mainScene?.id && selectedScenes.has(mainScene.id));
|
||||
})();
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>{children}</SheetTrigger>
|
||||
@@ -114,15 +119,19 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
||||
<DeleteDialog
|
||||
count={selectedScenes.size}
|
||||
onDelete={handleDeleteSelected}
|
||||
disabled={Array.from(selectedScenes).some(
|
||||
(id) => scenes.find((s) => s.id === id)?.isMain,
|
||||
)}
|
||||
>
|
||||
<Button className="rounded-md" variant="destructive" size="sm">
|
||||
<Trash2 />
|
||||
Delete ({selectedScenes.size})
|
||||
</Button>
|
||||
</DeleteDialog>
|
||||
disabled={isMainSceneSelected}
|
||||
trigger={
|
||||
<Button
|
||||
className="rounded-md"
|
||||
variant="destructive"
|
||||
disabled={isMainSceneSelected}
|
||||
size="sm"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete ({selectedScenes.size})
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{scenes.length === 0 ? (
|
||||
@@ -167,12 +176,12 @@ function DeleteDialog({
|
||||
count,
|
||||
onDelete,
|
||||
disabled,
|
||||
children,
|
||||
trigger,
|
||||
}: {
|
||||
count: number;
|
||||
onDelete: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
trigger: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -183,7 +192,7 @@ function DeleteDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Scenes</DialogTitle>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { Bookmark } from "lucide-react";
|
||||
|
||||
interface TimelineBookmarksRowProps {
|
||||
zoomLevel: number;
|
||||
dynamicTimelineWidth: number;
|
||||
bookmarksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||
handleRulerMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function TimelineBookmarksRow({
|
||||
zoomLevel,
|
||||
dynamicTimelineWidth,
|
||||
bookmarksScrollRef,
|
||||
handleWheel,
|
||||
handleTimelineContentClick,
|
||||
handleRulerMouseDown,
|
||||
}: TimelineBookmarksRowProps) {
|
||||
const editor = useEditor();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mt-0.5 h-4 flex-1 overflow-hidden"
|
||||
onWheel={handleWheel}
|
||||
onClick={handleTimelineContentClick}
|
||||
data-bookmarks-area
|
||||
>
|
||||
<ScrollArea className="scrollbar-hidden w-full" ref={bookmarksScrollRef}>
|
||||
<div
|
||||
className="relative h-4 cursor-default select-none"
|
||||
style={{
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
onMouseDown={handleRulerMouseDown}
|
||||
>
|
||||
{activeScene.bookmarks.map((time: number, index: number) => (
|
||||
<TimelineBookmark
|
||||
key={`bookmark-row-${index}`}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineBookmark({
|
||||
time,
|
||||
zoomLevel,
|
||||
}: {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const handleBookmarkClick = ({
|
||||
event,
|
||||
}: {
|
||||
event: React.MouseEvent<HTMLDivElement>;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
editor.playback.seek({ time });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 h-10 w-0.5 cursor-pointer"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
onClick={(event) => handleBookmarkClick({ event })}
|
||||
>
|
||||
<div className="text-primary absolute left-[-5px] top-[-1px]">
|
||||
<Bookmark className="fill-primary size-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { getDropLineY } from "@/lib/timeline/drop-utils";
|
||||
import type { TimelineTrack, DropTarget } from "@/types/timeline";
|
||||
|
||||
interface DragLineProps {
|
||||
dropTarget: DropTarget | null;
|
||||
tracks: TimelineTrack[];
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export function DragLine({ dropTarget, tracks, isVisible }: DragLineProps) {
|
||||
if (!isVisible || !dropTarget) return null;
|
||||
|
||||
const y = getDropLineY({ dropTarget, tracks });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-primary pointer-events-none absolute left-0 right-0 z-50 h-0.5"
|
||||
style={{ top: `${y}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
getTrackHeight,
|
||||
getCumulativeHeightBefore,
|
||||
getTotalTracksHeight,
|
||||
canTracktHaveAudio,
|
||||
canTrackBeHidden,
|
||||
} from "@/lib/timeline";
|
||||
import { TimelineToolbar } from "./timeline-toolbar";
|
||||
import { useScrollSync } from "@/hooks/use-scroll-sync";
|
||||
@@ -33,16 +35,18 @@ import { useElementSelection } from "@/hooks/use-element-selection";
|
||||
import { useTimelineInteractions } from "@/hooks/timeline/use-timeline-interactions";
|
||||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { TimelineRuler } from "./timeline-ruler";
|
||||
import { DragLine } from "./drag-line";
|
||||
import { TimelineBookmarksRow } from "./bookmarks";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
|
||||
export function Timeline() {
|
||||
const tracksContainerHeight = { min: 200, max: 800 };
|
||||
const { snappingEnabled } = useTimelineStore();
|
||||
const { clearSelection, setSelection } = useElementSelection();
|
||||
const editor = useEditor();
|
||||
const timeline = editor.timeline;
|
||||
const tracks = timeline.getTracks();
|
||||
const seek = (time: number) => editor.playback.seek({ time });
|
||||
|
||||
// Refs
|
||||
@@ -54,6 +58,7 @@ export function Timeline() {
|
||||
const trackLabelsRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const bookmarksScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State
|
||||
const [isInTimeline, setIsInTimeline] = useState(false);
|
||||
@@ -82,14 +87,11 @@ export function Timeline() {
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
|
||||
const timelineDuration = timeline.getTotalDuration() || 0;
|
||||
const paddedDuration =
|
||||
timelineDuration + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS;
|
||||
const dynamicTimelineWidth = Math.max(
|
||||
(timeline.getTotalDuration() || 0) *
|
||||
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
|
||||
zoomLevel,
|
||||
(editor.playback.getCurrentTime() +
|
||||
TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) *
|
||||
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
|
||||
zoomLevel,
|
||||
paddedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
timelineRef.current?.clientWidth || 1000,
|
||||
);
|
||||
|
||||
@@ -101,7 +103,7 @@ export function Timeline() {
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
|
||||
const { dragProps } = useTimelineDragDrop({
|
||||
containerRef: tracksContainerRef,
|
||||
zoomLevel,
|
||||
});
|
||||
@@ -110,13 +112,13 @@ export function Timeline() {
|
||||
selectionBox,
|
||||
handleMouseDown: handleSelectionMouseDown,
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
} = useSelectionBox({
|
||||
containerRef: tracksContainerRef,
|
||||
playheadRef,
|
||||
onSelectionComplete: (elements) => {
|
||||
setSelection(elements);
|
||||
},
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const showSnapIndicator =
|
||||
@@ -130,7 +132,6 @@ export function Timeline() {
|
||||
zoomLevel,
|
||||
duration: timeline.getTotalDuration(),
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
clearSelectedElements: clearSelection,
|
||||
seek,
|
||||
});
|
||||
@@ -139,6 +140,7 @@ export function Timeline() {
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsScrollRef,
|
||||
bookmarksScrollRef,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -174,31 +176,45 @@ export function Timeline() {
|
||||
<SnapIndicator
|
||||
snapPoint={currentSnapPoint}
|
||||
zoomLevel={zoomLevel}
|
||||
tracks={timeline.getTracks()}
|
||||
tracks={tracks}
|
||||
timelineRef={timelineRef}
|
||||
trackLabelsRef={trackLabelsRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
isVisible={showSnapIndicator}
|
||||
/>
|
||||
<div className="bg-panel sticky top-0 z-10 flex">
|
||||
<div className="bg-panel flex w-28 shrink-0 items-center justify-between border-r px-3 py-2">
|
||||
<span className="opacity-0">.</span>
|
||||
</div>
|
||||
<div className="bg-panel sticky top-0 z-10 flex flex-col">
|
||||
<div className="flex">
|
||||
<div className="bg-panel flex h-4 w-28 shrink-0 items-center justify-between border-r px-3">
|
||||
<span className="opacity-0">.</span>
|
||||
</div>
|
||||
|
||||
<TimelineRuler
|
||||
zoomLevel={zoomLevel}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
rulerRef={rulerRef}
|
||||
rulerScrollRef={rulerScrollRef}
|
||||
handleWheel={handleWheel}
|
||||
handleSelectionMouseDown={handleSelectionMouseDown}
|
||||
handleTimelineContentClick={handleTimelineContentClick}
|
||||
handleRulerMouseDown={handleRulerMouseDown}
|
||||
/>
|
||||
<TimelineRuler
|
||||
zoomLevel={zoomLevel}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
rulerRef={rulerRef}
|
||||
rulerScrollRef={rulerScrollRef}
|
||||
handleWheel={handleWheel}
|
||||
handleTimelineContentClick={handleTimelineContentClick}
|
||||
handleRulerMouseDown={handleRulerMouseDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="bg-panel flex h-6 w-28 shrink-0 items-center justify-between border-r px-3">
|
||||
<span className="opacity-0">.</span>
|
||||
</div>
|
||||
<TimelineBookmarksRow
|
||||
zoomLevel={zoomLevel}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
bookmarksScrollRef={bookmarksScrollRef}
|
||||
handleWheel={handleWheel}
|
||||
handleTimelineContentClick={handleTimelineContentClick}
|
||||
handleRulerMouseDown={handleRulerMouseDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{timeline.getTracks().length > 0 && (
|
||||
{tracks.length > 0 && (
|
||||
<div
|
||||
ref={trackLabelsRef}
|
||||
className="z-100 bg-panel w-28 shrink-0 overflow-y-auto border-r"
|
||||
@@ -206,7 +222,7 @@ export function Timeline() {
|
||||
>
|
||||
<ScrollArea className="h-full w-full" ref={trackLabelsScrollRef}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{timeline.getTracks().map((track) => (
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="group flex items-center px-3"
|
||||
@@ -215,26 +231,39 @@ export function Timeline() {
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
||||
{track.muted ? (
|
||||
<VolumeOff
|
||||
className="text-destructive h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Volume2
|
||||
{canTracktHaveAudio(track) && (
|
||||
<>
|
||||
{track.muted ? (
|
||||
<VolumeOff
|
||||
className="text-destructive h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Volume2
|
||||
className="text-muted-foreground h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canTrackBeHidden(track) && (
|
||||
<Eye
|
||||
className="text-muted-foreground h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
timeline.toggleTrackMute({
|
||||
timeline.toggleTrackHidden({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Eye className="text-muted-foreground h-4 w-4" />
|
||||
<TrackIcon track={track} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,50 +294,36 @@ export function Timeline() {
|
||||
containerRef={tracksContainerRef}
|
||||
isActive={selectionBox?.isActive || false}
|
||||
/>
|
||||
<DragLine
|
||||
dropTarget={dropTarget}
|
||||
tracks={timeline.getTracks()}
|
||||
isVisible={isDragOver}
|
||||
/>
|
||||
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
|
||||
<div
|
||||
className="relative flex-1"
|
||||
style={{
|
||||
height: `${Math.max(
|
||||
200,
|
||||
tracksContainerHeight.min,
|
||||
Math.min(
|
||||
800,
|
||||
getTotalTracksHeight({ tracks: timeline.getTracks() }),
|
||||
tracksContainerHeight.max,
|
||||
getTotalTracksHeight({ tracks }),
|
||||
),
|
||||
)}px`,
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
>
|
||||
{timeline.getTracks().length === 0 ? (
|
||||
{tracks.length === 0 ? (
|
||||
<div />
|
||||
) : (
|
||||
<>
|
||||
{timeline.getTracks().map((track, index) => (
|
||||
{tracks.map((track, index) => (
|
||||
<ContextMenu key={track.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
top: `${getCumulativeHeightBefore({
|
||||
tracks: timeline.getTracks(),
|
||||
tracks,
|
||||
trackIndex: index,
|
||||
})}px`,
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
!(e.target as HTMLElement).closest(
|
||||
".timeline-element",
|
||||
)
|
||||
) {
|
||||
clearSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TimelineTrackContent
|
||||
track={track}
|
||||
@@ -331,7 +346,9 @@ export function Timeline() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
{track.muted ? "Unmute Track" : "Mute Track"}
|
||||
{canTracktHaveAudio(track) && track.muted
|
||||
? "Unmute track"
|
||||
: "Mute track"}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={(e) => e.stopPropagation()}>
|
||||
Track settings (soon)
|
||||
|
||||
@@ -21,8 +21,8 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
getTrackClasses,
|
||||
getTrackHeight,
|
||||
canHaveAudio,
|
||||
canBeHidden,
|
||||
canElementHaveAudio,
|
||||
canElementBeHidden,
|
||||
hasMediaId,
|
||||
} from "@/lib/timeline";
|
||||
import {
|
||||
@@ -88,10 +88,8 @@ export function TimelineElement({
|
||||
selected.elementId === element.id && selected.trackId === track.id,
|
||||
);
|
||||
|
||||
const elementWidth = Math.max(
|
||||
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
|
||||
element.duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
);
|
||||
const elementWidth =
|
||||
element.duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const isBeingDragged = dragState.elementId === element.id;
|
||||
const elementStartTime =
|
||||
@@ -118,7 +116,7 @@ export function TimelineElement({
|
||||
}
|
||||
};
|
||||
|
||||
const isMuted = canHaveAudio(element) && element.muted === true;
|
||||
const isMuted = canElementHaveAudio(element) && element.muted === true;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -160,7 +158,7 @@ export function TimelineElement({
|
||||
selectedCount={selectedElements.length}
|
||||
onClick={(event) => handleAction({ action: "copy-selected", event })}
|
||||
/>
|
||||
{canHaveAudio(element) && hasAudio && (
|
||||
{canElementHaveAudio(element) && hasAudio && (
|
||||
<MuteMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
@@ -172,7 +170,7 @@ export function TimelineElement({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{canBeHidden(element) && (
|
||||
{canElementBeHidden(element) && (
|
||||
<VisibilityMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
@@ -262,7 +260,7 @@ function ElementInner({
|
||||
{
|
||||
type: track.type,
|
||||
},
|
||||
)} ${isBeingDragged ? "z-50" : "z-10"} ${canBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
)} ${isBeingDragged ? "z-50" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
onClick={(e) => onElementClick(e, element)}
|
||||
onMouseDown={(e) => onElementMouseDown(e, element)}
|
||||
onContextMenu={(e) => onElementMouseDown(e, element)}
|
||||
@@ -276,7 +274,7 @@ function ElementInner({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(hasAudio ? isMuted : canBeHidden(element) && element.hidden) && (
|
||||
{(hasAudio ? isMuted : canElementBeHidden(element) && element.hidden) && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
||||
{hasAudio ? (
|
||||
<VolumeX className="size-6 text-white" />
|
||||
@@ -397,13 +395,15 @@ function ElementContent({
|
||||
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-[0.25rem] left-0 right-0 top-[0.25rem]"
|
||||
className="absolute left-0 right-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",
|
||||
}}
|
||||
aria-label={`Tiled ${mediaAsset.type === "image" ? "background" : "thumbnail"} of ${mediaAsset.name}`}
|
||||
/>
|
||||
@@ -493,7 +493,7 @@ function VisibilityMenuItem({
|
||||
selectedCount: number;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const isHidden = canBeHidden(element) && element.hidden;
|
||||
const isHidden = canElementBeHidden(element) && element.hidden;
|
||||
|
||||
const getIcon = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { Bookmark } from "lucide-react";
|
||||
import { TimelineTick } from "./timeline-tick";
|
||||
|
||||
interface TimelineRulerProps {
|
||||
@@ -10,7 +10,6 @@ interface TimelineRulerProps {
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleSelectionMouseDown: (e: React.MouseEvent) => void;
|
||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||
handleRulerMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
@@ -21,112 +20,80 @@ export function TimelineRuler({
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
handleWheel,
|
||||
handleSelectionMouseDown,
|
||||
handleTimelineContentClick,
|
||||
handleRulerMouseDown,
|
||||
}: TimelineRulerProps) {
|
||||
const editor = useEditor();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
const interval = getOptimalTimeInterval({ zoomLevel });
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const tickSpacingPixels = interval * pixelsPerSecond;
|
||||
const minLabelSpacingPixels = 120;
|
||||
const labelEvery = Math.max(
|
||||
1,
|
||||
Math.ceil(minLabelSpacingPixels / tickSpacingPixels),
|
||||
);
|
||||
const markerCount = Math.ceil(duration / interval) + 1;
|
||||
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
|
||||
const effectiveDuration = Math.max(duration, visibleDuration);
|
||||
const project = editor.project.getActiveOrNull();
|
||||
const fps = project?.settings.fps ?? DEFAULT_FPS;
|
||||
|
||||
const interval = getOptimalTimeInterval({ zoomLevel, fps });
|
||||
const markerCount = Math.ceil(effectiveDuration / interval) + 1;
|
||||
|
||||
const timelineTicks: Array<JSX.Element> = [];
|
||||
for (let markerIndex = 0; markerIndex < markerCount; markerIndex += 1) {
|
||||
const time = markerIndex * interval;
|
||||
if (time > duration) break;
|
||||
if (time > effectiveDuration) break;
|
||||
|
||||
timelineTicks.push(
|
||||
<TimelineTick
|
||||
key={markerIndex}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
interval={interval}
|
||||
shouldShowLabel={markerIndex % labelEvery === 0}
|
||||
/>,
|
||||
<TimelineTick key={markerIndex} time={time} zoomLevel={zoomLevel} />,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-10 flex-1 overflow-hidden"
|
||||
className="relative h-4 flex-1 overflow-hidden"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleSelectionMouseDown}
|
||||
onClick={handleTimelineContentClick}
|
||||
data-ruler-area
|
||||
>
|
||||
<ScrollArea className="w-full" ref={rulerScrollRef}>
|
||||
<ScrollArea className="scrollbar-hidden w-full" ref={rulerScrollRef}>
|
||||
<div
|
||||
ref={rulerRef}
|
||||
className="relative h-10 cursor-default select-none"
|
||||
className="relative h-4 cursor-default select-none"
|
||||
style={{
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
onMouseDown={handleRulerMouseDown}
|
||||
>
|
||||
{timelineTicks}
|
||||
|
||||
{activeScene.bookmarks.map((time: number, index: number) => (
|
||||
<TimelineBookmark
|
||||
key={`bookmark-${index}`}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineBookmark({
|
||||
time,
|
||||
function getOptimalTimeInterval({
|
||||
zoomLevel,
|
||||
fps,
|
||||
}: {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
fps: number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const handleBookmarkClick = ({
|
||||
event,
|
||||
}: {
|
||||
event: React.MouseEvent<HTMLDivElement>;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
editor.playback.seek({ time });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="!bg-primary absolute top-0 h-10 w-0.5 cursor-pointer"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
onClick={(event) => handleBookmarkClick({ event })}
|
||||
>
|
||||
<div className="text-primary absolute left-[-5px] top-[-1px]">
|
||||
<Bookmark className="fill-primary size-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getOptimalTimeInterval({ zoomLevel }: { zoomLevel: number }) {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
if (pixelsPerSecond >= 200) return 0.1;
|
||||
if (pixelsPerSecond >= 100) return 0.5;
|
||||
if (pixelsPerSecond >= 50) return 1;
|
||||
if (pixelsPerSecond >= 25) return 2;
|
||||
if (pixelsPerSecond >= 12) return 5;
|
||||
if (pixelsPerSecond >= 6) return 10;
|
||||
return 30;
|
||||
const pixelsPerFrame = pixelsPerSecond / fps;
|
||||
const minPixelSpacing = 18;
|
||||
const minFrames = minPixelSpacing / pixelsPerFrame;
|
||||
const baseIntervals = [1, 3, 6, 12, 15, 30];
|
||||
const maxInterval = fps * 10;
|
||||
const niceIntervals: Array<number> = [...baseIntervals];
|
||||
|
||||
let currentInterval = baseIntervals[baseIntervals.length - 1];
|
||||
while (currentInterval < maxInterval) {
|
||||
currentInterval *= 2;
|
||||
niceIntervals.push(currentInterval);
|
||||
}
|
||||
|
||||
for (const intervalFrames of niceIntervals) {
|
||||
if (intervalFrames >= minFrames) {
|
||||
return intervalFrames / fps;
|
||||
}
|
||||
}
|
||||
|
||||
return niceIntervals[niceIntervals.length - 1] / fps;
|
||||
}
|
||||
|
||||
@@ -5,61 +5,15 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
interface TimelineTickProps {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
interval: number;
|
||||
shouldShowLabel: boolean;
|
||||
}
|
||||
|
||||
export function TimelineTick({
|
||||
time,
|
||||
zoomLevel,
|
||||
interval,
|
||||
shouldShowLabel,
|
||||
}: TimelineTickProps) {
|
||||
export function TimelineTick({ time, zoomLevel }: TimelineTickProps) {
|
||||
return (
|
||||
<div
|
||||
className="border-muted-foreground/20 absolute top-0 h-4 border-l"
|
||||
className="border-muted-foreground/40 absolute top-1 h-1.5 border-l"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
>
|
||||
{shouldShowLabel ? (
|
||||
<span className="text-muted-foreground/70 absolute left-1 top-1 text-[0.6rem]">
|
||||
{formatTimelineTickLabel({ timeInSeconds: time, interval })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimelineTickLabel({
|
||||
timeInSeconds,
|
||||
interval,
|
||||
}: {
|
||||
timeInSeconds: number;
|
||||
interval: number;
|
||||
}): string {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const secondsRemainder = timeInSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
const paddedMinutes = minutes.toString().padStart(2, "0");
|
||||
const paddedSeconds = Math.floor(secondsRemainder)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
const paddedSeconds = Math.floor(secondsRemainder)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${minutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
if (interval >= 1) {
|
||||
return `${Math.floor(secondsRemainder)}s`;
|
||||
}
|
||||
|
||||
return `${secondsRemainder.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
@@ -98,43 +98,21 @@ function ToolbarLeftSection() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-play", event })
|
||||
}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={isPlaying ? <Pause /> : <Play />}
|
||||
tooltip={isPlaying ? "Pause" : "Play"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-play", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) => handleAction({ action: "goto-start", event })}
|
||||
>
|
||||
<SkipBack className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<SkipBack />}
|
||||
tooltip="Go to start"
|
||||
onClick={({ event }) => handleAction({ action: "goto-start", event })}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
<div className="bg-border mx-2 h-10 w-px" />
|
||||
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
@@ -142,137 +120,76 @@ function ToolbarLeftSection() {
|
||||
fps={activeProject.settings.fps}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
<div className="bg-border mx-1 h-10 w-px" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Scissors className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Scissors />}
|
||||
tooltip="Split element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split-selected", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "left",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArrowLeftToLine className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<ArrowLeftToLine />}
|
||||
tooltip="Split and keep left"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split-selected-left", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "right",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArrowRightToLine className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<ArrowRightToLine />}
|
||||
tooltip="Split and keep right"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split-selected-right", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" disabled type="button">
|
||||
<SplitSquareHorizontal className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (Coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<SplitSquareHorizontal />}
|
||||
tooltip="Coming soon" /* Separate audio */
|
||||
disabled={true}
|
||||
onClick={({ event }) => {}}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "duplicate-selected", event })
|
||||
}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Copy />}
|
||||
tooltip="Duplicate element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "duplicate-selected", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" disabled>
|
||||
<Snowflake className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Freeze frame (F) (Coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Snowflake />}
|
||||
tooltip="Coming soon" /* Freeze frame */
|
||||
disabled={true}
|
||||
onClick={({ event }) => {}}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "delete-selected", event })
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete element (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Trash2 />}
|
||||
tooltip="Delete element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "delete-selected", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-bookmark", event })
|
||||
}
|
||||
>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<Bookmark
|
||||
className={`size-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
||||
className={currentBookmarked ? "fill-primary text-primary" : ""}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
</TooltipContent>
|
||||
}
|
||||
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-bookmark", event })
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
@@ -348,23 +265,17 @@ function ToolbarRightSection({
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" onClick={() => {}}>
|
||||
<Magnet className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto snapping</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Magnet />}
|
||||
tooltip="Auto snapping"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" onClick={() => {}}>
|
||||
<Link className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Enable Ripple Editing</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<Link />}
|
||||
tooltip="Ripple editing"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
@@ -376,7 +287,7 @@ function ToolbarRightSection({
|
||||
type="button"
|
||||
onClick={() => onZoom({ direction: "out" })}
|
||||
>
|
||||
<ZoomOut className="size-4" />
|
||||
<ZoomOut />
|
||||
</Button>
|
||||
<Slider
|
||||
className="w-24"
|
||||
@@ -392,9 +303,38 @@ function ToolbarRightSection({
|
||||
type="button"
|
||||
onClick={() => onZoom({ direction: "in" })}
|
||||
>
|
||||
<ZoomIn className="size-4" />
|
||||
<ZoomIn />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
icon,
|
||||
tooltip,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) => onClick({ event })}
|
||||
className={disabled ? "cursor-not-allowed opacity-50" : ""}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export function KeyboardShortcutsHelp() {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="text" size="sm" className="gap-2">
|
||||
<Keyboard className="w-4 h-4" />
|
||||
<Keyboard />
|
||||
Shortcuts
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -322,7 +322,7 @@ const ColorCircle = ({
|
||||
}) => (
|
||||
<div
|
||||
className={`absolute border-3 border-white rounded-full shadow-lg pointer-events-none ${
|
||||
size === "sm" ? "w-3 h-3" : "w-4 h-4"
|
||||
size === "sm" ? "size-3" : "size-4"
|
||||
}`}
|
||||
style={{
|
||||
left: position.left,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { motion } from "motion/react";
|
||||
import { ComponentType } from "react";
|
||||
|
||||
interface SponsorButtonProps {
|
||||
href: string;
|
||||
logo: ComponentType<{ className?: string }>;
|
||||
companyName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SponsorButton({
|
||||
href,
|
||||
logo: Logo,
|
||||
companyName,
|
||||
className = "",
|
||||
}: SponsorButtonProps) {
|
||||
return (
|
||||
<motion.a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md border border-border bg-background/5 backdrop-blur-xs hover:bg-background/10 transition-all duration-200 group shadow-lg ${className}`}
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
Sponsored by
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="text-foreground/90 group-hover:text-foreground transition-colors">
|
||||
<Logo className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-medium text-foreground/90 group-hover:text-foreground transition-colors">
|
||||
{companyName}
|
||||
</span>
|
||||
</div>
|
||||
</motion.a>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user