mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: scenes button to timeline toolbar
This commit is contained in:
@@ -1,411 +1,419 @@
|
|||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Magnet,
|
Magnet,
|
||||||
Link,
|
Link,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
Copy,
|
Copy,
|
||||||
Trash2,
|
Trash2,
|
||||||
Snowflake,
|
Snowflake,
|
||||||
ArrowLeftToLine,
|
ArrowLeftToLine,
|
||||||
ArrowRightToLine,
|
ArrowRightToLine,
|
||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
Scissors,
|
Scissors,
|
||||||
} from "lucide-react";
|
LayersIcon,
|
||||||
import { Slider } from "@/components/ui/slider";
|
} from "lucide-react";
|
||||||
import { DEFAULT_FPS } from "@/stores/project-store";
|
import {
|
||||||
import { formatTimeCode } from "@/lib/time";
|
SplitButton,
|
||||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
SplitButtonLeft,
|
||||||
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
SplitButtonRight,
|
||||||
|
SplitButtonSeparator,
|
||||||
export function TimelineToolbar({
|
} from "@/components/ui/split-button";
|
||||||
zoomLevel,
|
import { Slider } from "@/components/ui/slider";
|
||||||
setZoomLevel,
|
import { DEFAULT_FPS } from "@/stores/project-store";
|
||||||
}: {
|
import { formatTimeCode } from "@/lib/time";
|
||||||
zoomLevel: number;
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
setZoomLevel: (zoom: number) => void;
|
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||||
}) {
|
|
||||||
const {
|
export function TimelineToolbar({
|
||||||
tracks,
|
zoomLevel,
|
||||||
addTrack,
|
setZoomLevel,
|
||||||
addElementToTrack,
|
}: {
|
||||||
removeElementFromTrack,
|
zoomLevel: number;
|
||||||
removeElementFromTrackWithRipple,
|
setZoomLevel: (zoom: number) => void;
|
||||||
selectedElements,
|
}) {
|
||||||
clearSelectedElements,
|
const {
|
||||||
splitElement,
|
tracks,
|
||||||
splitAndKeepLeft,
|
addTrack,
|
||||||
splitAndKeepRight,
|
addElementToTrack,
|
||||||
separateAudio,
|
removeElementFromTrack,
|
||||||
snappingEnabled,
|
removeElementFromTrackWithRipple,
|
||||||
toggleSnapping,
|
selectedElements,
|
||||||
rippleEditingEnabled,
|
clearSelectedElements,
|
||||||
toggleRippleEditing,
|
splitElement,
|
||||||
} = useTimelineStore();
|
splitAndKeepLeft,
|
||||||
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
|
splitAndKeepRight,
|
||||||
const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
|
separateAudio,
|
||||||
|
snappingEnabled,
|
||||||
const handleSplitSelected = () => {
|
toggleSnapping,
|
||||||
if (selectedElements.length === 0) return;
|
rippleEditingEnabled,
|
||||||
let splitCount = 0;
|
toggleRippleEditing,
|
||||||
selectedElements.forEach(({ trackId, elementId }) => {
|
} = useTimelineStore();
|
||||||
const track = tracks.find((t) => t.id === trackId);
|
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
|
||||||
const element = track?.elements.find((c) => c.id === elementId);
|
const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
|
||||||
if (element && track) {
|
|
||||||
const effectiveStart = element.startTime;
|
const handleSplitSelected = () => {
|
||||||
const effectiveEnd =
|
if (selectedElements.length === 0) return;
|
||||||
element.startTime +
|
let splitCount = 0;
|
||||||
(element.duration - element.trimStart - element.trimEnd);
|
selectedElements.forEach(({ trackId, elementId }) => {
|
||||||
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
|
const track = tracks.find((t) => t.id === trackId);
|
||||||
const newElementId = splitElement(trackId, elementId, currentTime);
|
const element = track?.elements.find((c) => c.id === elementId);
|
||||||
if (newElementId) splitCount++;
|
if (element && track) {
|
||||||
}
|
const effectiveStart = element.startTime;
|
||||||
}
|
const effectiveEnd =
|
||||||
});
|
element.startTime +
|
||||||
if (splitCount === 0) {
|
(element.duration - element.trimStart - element.trimEnd);
|
||||||
toast.error("Playhead must be within selected elements to split");
|
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
|
||||||
}
|
const newElementId = splitElement(trackId, elementId, currentTime);
|
||||||
};
|
if (newElementId) splitCount++;
|
||||||
|
}
|
||||||
const handleDuplicateSelected = () => {
|
}
|
||||||
if (selectedElements.length === 0) return;
|
});
|
||||||
const canDuplicate = selectedElements.length === 1;
|
if (splitCount === 0) {
|
||||||
if (!canDuplicate) return;
|
toast.error("Playhead must be within selected elements to split");
|
||||||
|
}
|
||||||
selectedElements.forEach(({ trackId, elementId }) => {
|
};
|
||||||
const track = tracks.find((t) => t.id === trackId);
|
|
||||||
const element = track?.elements.find((el) => el.id === elementId);
|
const handleDuplicateSelected = () => {
|
||||||
if (element) {
|
if (selectedElements.length === 0) return;
|
||||||
const newStartTime =
|
const canDuplicate = selectedElements.length === 1;
|
||||||
element.startTime +
|
if (!canDuplicate) return;
|
||||||
(element.duration - element.trimStart - element.trimEnd) +
|
|
||||||
0.1;
|
selectedElements.forEach(({ trackId, elementId }) => {
|
||||||
const { id, ...elementWithoutId } = element;
|
const track = tracks.find((t) => t.id === trackId);
|
||||||
addElementToTrack(trackId, {
|
const element = track?.elements.find((el) => el.id === elementId);
|
||||||
...elementWithoutId,
|
if (element) {
|
||||||
startTime: newStartTime,
|
const newStartTime =
|
||||||
});
|
element.startTime +
|
||||||
}
|
(element.duration - element.trimStart - element.trimEnd) +
|
||||||
});
|
0.1;
|
||||||
clearSelectedElements();
|
const { id, ...elementWithoutId } = element;
|
||||||
};
|
addElementToTrack(trackId, {
|
||||||
|
...elementWithoutId,
|
||||||
const handleFreezeSelected = () => {
|
startTime: newStartTime,
|
||||||
toast.info("Freeze frame functionality coming soon!");
|
});
|
||||||
};
|
}
|
||||||
|
});
|
||||||
const handleSplitAndKeepLeft = () => {
|
clearSelectedElements();
|
||||||
if (selectedElements.length !== 1) {
|
};
|
||||||
toast.error("Select exactly one element");
|
|
||||||
return;
|
const handleFreezeSelected = () => {
|
||||||
}
|
toast.info("Freeze frame functionality coming soon!");
|
||||||
const { trackId, elementId } = selectedElements[0];
|
};
|
||||||
const track = tracks.find((t) => t.id === trackId);
|
|
||||||
const element = track?.elements.find((c) => c.id === elementId);
|
const handleSplitAndKeepLeft = () => {
|
||||||
if (!element) return;
|
if (selectedElements.length !== 1) {
|
||||||
const effectiveStart = element.startTime;
|
toast.error("Select exactly one element");
|
||||||
const effectiveEnd =
|
return;
|
||||||
element.startTime +
|
}
|
||||||
(element.duration - element.trimStart - element.trimEnd);
|
const { trackId, elementId } = selectedElements[0];
|
||||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
const track = tracks.find((t) => t.id === trackId);
|
||||||
toast.error("Playhead must be within selected element");
|
const element = track?.elements.find((c) => c.id === elementId);
|
||||||
return;
|
if (!element) return;
|
||||||
}
|
const effectiveStart = element.startTime;
|
||||||
splitAndKeepLeft(trackId, elementId, currentTime);
|
const effectiveEnd =
|
||||||
};
|
element.startTime +
|
||||||
|
(element.duration - element.trimStart - element.trimEnd);
|
||||||
const handleSplitAndKeepRight = () => {
|
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||||
if (selectedElements.length !== 1) {
|
toast.error("Playhead must be within selected element");
|
||||||
toast.error("Select exactly one element");
|
return;
|
||||||
return;
|
}
|
||||||
}
|
splitAndKeepLeft(trackId, elementId, currentTime);
|
||||||
const { trackId, elementId } = selectedElements[0];
|
};
|
||||||
const track = tracks.find((t) => t.id === trackId);
|
|
||||||
const element = track?.elements.find((c) => c.id === elementId);
|
const handleSplitAndKeepRight = () => {
|
||||||
if (!element) return;
|
if (selectedElements.length !== 1) {
|
||||||
const effectiveStart = element.startTime;
|
toast.error("Select exactly one element");
|
||||||
const effectiveEnd =
|
return;
|
||||||
element.startTime +
|
}
|
||||||
(element.duration - element.trimStart - element.trimEnd);
|
const { trackId, elementId } = selectedElements[0];
|
||||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
const track = tracks.find((t) => t.id === trackId);
|
||||||
toast.error("Playhead must be within selected element");
|
const element = track?.elements.find((c) => c.id === elementId);
|
||||||
return;
|
if (!element) return;
|
||||||
}
|
const effectiveStart = element.startTime;
|
||||||
splitAndKeepRight(trackId, elementId, currentTime);
|
const effectiveEnd =
|
||||||
};
|
element.startTime +
|
||||||
|
(element.duration - element.trimStart - element.trimEnd);
|
||||||
const handleSeparateAudio = () => {
|
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||||
if (selectedElements.length !== 1) {
|
toast.error("Playhead must be within selected element");
|
||||||
toast.error("Select exactly one media element to separate audio");
|
return;
|
||||||
return;
|
}
|
||||||
}
|
splitAndKeepRight(trackId, elementId, currentTime);
|
||||||
const { trackId, elementId } = selectedElements[0];
|
};
|
||||||
const track = tracks.find((t) => t.id === trackId);
|
|
||||||
if (!track || track.type !== "media") {
|
const handleSeparateAudio = () => {
|
||||||
toast.error("Select a media element to separate audio");
|
if (selectedElements.length !== 1) {
|
||||||
return;
|
toast.error("Select exactly one media element to separate audio");
|
||||||
}
|
return;
|
||||||
separateAudio(trackId, elementId);
|
}
|
||||||
};
|
const { trackId, elementId } = selectedElements[0];
|
||||||
|
const track = tracks.find((t) => t.id === trackId);
|
||||||
const handleDeleteSelected = () => {
|
if (!track || track.type !== "media") {
|
||||||
if (selectedElements.length === 0) return;
|
toast.error("Select a media element to separate audio");
|
||||||
selectedElements.forEach(({ trackId, elementId }) => {
|
return;
|
||||||
if (rippleEditingEnabled) {
|
}
|
||||||
removeElementFromTrackWithRipple(trackId, elementId);
|
separateAudio(trackId, elementId);
|
||||||
} else {
|
};
|
||||||
removeElementFromTrack(trackId, elementId);
|
|
||||||
}
|
const handleDeleteSelected = () => {
|
||||||
});
|
if (selectedElements.length === 0) return;
|
||||||
clearSelectedElements();
|
selectedElements.forEach(({ trackId, elementId }) => {
|
||||||
};
|
if (rippleEditingEnabled) {
|
||||||
|
removeElementFromTrackWithRipple(trackId, elementId);
|
||||||
const handleZoomIn = () => {
|
} else {
|
||||||
setZoomLevel(Math.min(4, zoomLevel + 0.25));
|
removeElementFromTrack(trackId, elementId);
|
||||||
};
|
}
|
||||||
|
});
|
||||||
const handleZoomOut = () => {
|
clearSelectedElements();
|
||||||
setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
|
};
|
||||||
};
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
const handleZoomSliderChange = (values: number[]) => {
|
setZoomLevel(Math.min(4, zoomLevel + 0.25));
|
||||||
setZoomLevel(values[0]);
|
};
|
||||||
};
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
const handleToggleBookmark = async () => {
|
setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
|
||||||
await toggleBookmark(currentTime);
|
};
|
||||||
};
|
|
||||||
|
const handleZoomSliderChange = (values: number[]) => {
|
||||||
const currentBookmarked = isBookmarked(currentTime);
|
setZoomLevel(values[0]);
|
||||||
return (
|
};
|
||||||
<div className=" flex items-center justify-between px-2 py-1 border-b">
|
|
||||||
<div className="flex items-center gap-1 w-full">
|
const handleToggleBookmark = async () => {
|
||||||
<TooltipProvider delayDuration={500}>
|
await toggleBookmark(currentTime);
|
||||||
<Tooltip>
|
};
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
const currentBookmarked = isBookmarked(currentTime);
|
||||||
variant="text"
|
return (
|
||||||
size="icon"
|
<div className="flex items-center justify-between px-2 py-1 border-b h-10">
|
||||||
onClick={toggle}
|
<div className="flex items-center gap-1">
|
||||||
>
|
<TooltipProvider delayDuration={500}>
|
||||||
{isPlaying ? (
|
<Tooltip>
|
||||||
<Pause className="h-4 w-4" />
|
<TooltipTrigger asChild>
|
||||||
) : (
|
<Button variant="text" size="icon" onClick={toggle}>
|
||||||
<Play className="h-4 w-4" />
|
{isPlaying ? (
|
||||||
)}
|
<Pause className="h-4 w-4" />
|
||||||
</Button>
|
) : (
|
||||||
</TooltipTrigger>
|
<Play className="h-4 w-4" />
|
||||||
<TooltipContent>
|
)}
|
||||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
</Button>
|
||||||
</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent>
|
||||||
<Tooltip>
|
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||||
<TooltipTrigger asChild>
|
</TooltipContent>
|
||||||
<Button
|
</Tooltip>
|
||||||
variant="text"
|
<Tooltip>
|
||||||
size="icon"
|
<TooltipTrigger asChild>
|
||||||
onClick={() => seek(0)}
|
<Button variant="text" size="icon" onClick={() => seek(0)}>
|
||||||
>
|
<SkipBack className="h-4 w-4" />
|
||||||
<SkipBack className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
|
||||||
<TooltipContent>Return to Start (Home / Enter)</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
<div className="w-px h-6 bg-border mx-1" />
|
{/* Time Display */}
|
||||||
{/* Time Display */}
|
<div className="flex flex-row items-center justify-center px-2">
|
||||||
<div className="flex flex-row items-center justify-center px-2">
|
<EditableTimecode
|
||||||
<EditableTimecode
|
time={currentTime}
|
||||||
time={currentTime}
|
duration={duration}
|
||||||
duration={duration}
|
format="HH:MM:SS:FF"
|
||||||
format="HH:MM:SS:FF"
|
fps={activeProject?.fps ?? DEFAULT_FPS}
|
||||||
fps={activeProject?.fps ?? DEFAULT_FPS}
|
onTimeChange={seek}
|
||||||
onTimeChange={seek}
|
className="text-center"
|
||||||
className="text-center"
|
/>
|
||||||
/>
|
<div className="text-xs text-muted-foreground font-mono px-2">
|
||||||
<div className="text-xs text-muted-foreground font-mono px-2">
|
/
|
||||||
/
|
</div>
|
||||||
</div>
|
<div className="text-xs text-muted-foreground font-mono text-center">
|
||||||
<div className="text-xs text-muted-foreground font-mono text-center">
|
{formatTimeCode(duration, "HH:MM:SS:FF")}
|
||||||
{formatTimeCode(duration, "HH:MM:SS:FF")}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{tracks.length === 0 && (
|
||||||
{tracks.length === 0 && (
|
<>
|
||||||
<>
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
<div className="w-px h-6 bg-border mx-1" />
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => {
|
||||||
onClick={() => {
|
const trackId = addTrack("media");
|
||||||
const trackId = addTrack("media");
|
addElementToTrack(trackId, {
|
||||||
addElementToTrack(trackId, {
|
type: "media",
|
||||||
type: "media",
|
mediaId: "test",
|
||||||
mediaId: "test",
|
name: "Test Clip",
|
||||||
name: "Test Clip",
|
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
startTime: 0,
|
||||||
startTime: 0,
|
trimStart: 0,
|
||||||
trimStart: 0,
|
trimEnd: 0,
|
||||||
trimEnd: 0,
|
});
|
||||||
});
|
}}
|
||||||
}}
|
className="text-xs"
|
||||||
className="text-xs"
|
>
|
||||||
>
|
Add Test Clip
|
||||||
Add Test Clip
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Add a test clip to try playback</TooltipContent>
|
||||||
<TooltipContent>Add a test clip to try playback</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
<div className="w-px h-6 bg-border mx-1" />
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant="text" size="icon" onClick={handleSplitSelected}>
|
||||||
<Button variant="text" size="icon" onClick={handleSplitSelected}>
|
<Scissors className="h-4 w-4" />
|
||||||
<Scissors className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
|
||||||
<TooltipContent>Split element (Ctrl+S)</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="text"
|
||||||
variant="text"
|
size="icon"
|
||||||
size="icon"
|
onClick={handleSplitAndKeepLeft}
|
||||||
onClick={handleSplitAndKeepLeft}
|
>
|
||||||
>
|
<ArrowLeftToLine className="h-4 w-4" />
|
||||||
<ArrowLeftToLine className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
|
||||||
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="text"
|
||||||
variant="text"
|
size="icon"
|
||||||
size="icon"
|
onClick={handleSplitAndKeepRight}
|
||||||
onClick={handleSplitAndKeepRight}
|
>
|
||||||
>
|
<ArrowRightToLine className="h-4 w-4" />
|
||||||
<ArrowRightToLine className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
|
||||||
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
|
||||||
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
|
<SplitSquareHorizontal className="h-4 w-4" />
|
||||||
<SplitSquareHorizontal className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
|
||||||
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="text"
|
||||||
variant="text"
|
size="icon"
|
||||||
size="icon"
|
onClick={handleDuplicateSelected}
|
||||||
onClick={handleDuplicateSelected}
|
>
|
||||||
>
|
<Copy className="h-4 w-4" />
|
||||||
<Copy className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
|
||||||
<TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
|
||||||
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
|
<Snowflake className="h-4 w-4" />
|
||||||
<Snowflake className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Freeze frame (F)</TooltipContent>
|
||||||
<TooltipContent>Freeze frame (F)</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
|
||||||
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
|
<Trash2 className="h-4 w-4" />
|
||||||
<Trash2 className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Delete element (Delete)</TooltipContent>
|
||||||
<TooltipContent>Delete element (Delete)</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
<div className="w-px h-6 bg-border mx-1" />
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant="text" size="icon" onClick={handleToggleBookmark}>
|
||||||
<Button variant="text" size="icon" onClick={handleToggleBookmark}>
|
<Bookmark
|
||||||
<Bookmark
|
className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
||||||
className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
/>
|
||||||
/>
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>
|
||||||
<TooltipContent>
|
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||||
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
</TooltipProvider>
|
||||||
</TooltipProvider>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div className="flex items-center gap-1">
|
<SplitButton>
|
||||||
<TooltipProvider delayDuration={500}>
|
<SplitButtonLeft>Main scene</SplitButtonLeft>
|
||||||
<Tooltip>
|
<SplitButtonSeparator />
|
||||||
<TooltipTrigger asChild>
|
<SplitButtonRight onClick={() => {}}>
|
||||||
<Button variant="text" size="icon" onClick={toggleSnapping}>
|
<LayersIcon />
|
||||||
{snappingEnabled ? (
|
</SplitButtonRight>
|
||||||
<Magnet className="h-4 w-4 text-primary" />
|
</SplitButton>
|
||||||
) : (
|
</div>
|
||||||
<Magnet className="h-4 w-4" />
|
<div className="flex items-center gap-1">
|
||||||
)}
|
<TooltipProvider delayDuration={500}>
|
||||||
</Button>
|
<Tooltip>
|
||||||
</TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<TooltipContent>Auto snapping</TooltipContent>
|
<Button variant="text" size="icon" onClick={toggleSnapping}>
|
||||||
</Tooltip>
|
{snappingEnabled ? (
|
||||||
<Tooltip>
|
<Magnet className="h-4 w-4 text-primary" />
|
||||||
<TooltipTrigger asChild>
|
) : (
|
||||||
<Button variant="text" size="icon" onClick={toggleRippleEditing}>
|
<Magnet className="h-4 w-4" />
|
||||||
<Link
|
)}
|
||||||
className={`h-4 w-4 ${
|
</Button>
|
||||||
rippleEditingEnabled ? "text-primary" : ""
|
</TooltipTrigger>
|
||||||
}`}
|
<TooltipContent>Auto snapping</TooltipContent>
|
||||||
/>
|
</Tooltip>
|
||||||
</Button>
|
<Tooltip>
|
||||||
</TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<TooltipContent>
|
<Button variant="text" size="icon" onClick={toggleRippleEditing}>
|
||||||
{rippleEditingEnabled
|
<Link
|
||||||
? "Disable Ripple Editing"
|
className={`h-4 w-4 ${
|
||||||
: "Enable Ripple Editing"}
|
rippleEditingEnabled ? "text-primary" : ""
|
||||||
</TooltipContent>
|
}`}
|
||||||
</Tooltip>
|
/>
|
||||||
</TooltipProvider>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
<div className="h-6 w-px bg-border mx-1" />
|
<TooltipContent>
|
||||||
<div className="flex items-center gap-1">
|
{rippleEditingEnabled
|
||||||
<Button variant="text" size="icon" onClick={handleZoomOut}>
|
? "Disable Ripple Editing"
|
||||||
<ZoomOut className="h-4 w-4" />
|
: "Enable Ripple Editing"}
|
||||||
</Button>
|
</TooltipContent>
|
||||||
<Slider
|
</Tooltip>
|
||||||
className="w-24"
|
</TooltipProvider>
|
||||||
value={[zoomLevel]}
|
|
||||||
onValueChange={handleZoomSliderChange}
|
<div className="h-6 w-px bg-border mx-1" />
|
||||||
min={0.25}
|
<div className="flex items-center gap-1">
|
||||||
max={4}
|
<Button variant="text" size="icon" onClick={handleZoomOut}>
|
||||||
step={0.25}
|
<ZoomOut className="h-4 w-4" />
|
||||||
/>
|
</Button>
|
||||||
<Button variant="text" size="icon" onClick={handleZoomIn}>
|
<Slider
|
||||||
<ZoomIn className="h-4 w-4" />
|
className="w-24"
|
||||||
</Button>
|
value={[zoomLevel]}
|
||||||
</div>
|
onValueChange={handleZoomSliderChange}
|
||||||
</div>
|
min={0.25}
|
||||||
</div>
|
max={4}
|
||||||
);
|
step={0.25}
|
||||||
}
|
/>
|
||||||
|
<Button variant="text" size="icon" onClick={handleZoomIn}>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Button, ButtonProps } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ReactNode, forwardRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SplitButtonProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
|
||||||
|
({ children, className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex rounded-full h-7 border border-input bg-panel-accent overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
SplitButton.displayName = "SplitButton";
|
||||||
|
|
||||||
|
const SplitButtonSide = forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
SplitButtonSideProps & { paddingClass: string }
|
||||||
|
>(({ children, className, paddingClass, onClick, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="text"
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-none bg-panel-accent !opacity-100 border-0 gap-0 font-normal transition-colors",
|
||||||
|
onClick
|
||||||
|
? "hover:bg-foreground/10 hover:opacity-100 cursor-pointer"
|
||||||
|
: "cursor-default select-text",
|
||||||
|
paddingClass,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{typeof children === "string" ? (
|
||||||
|
<span className="font-normal cursor-text">{children}</span>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SplitButtonSide.displayName = "SplitButtonSide";
|
||||||
|
|
||||||
|
const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
|
||||||
|
({ ...props }, ref) => {
|
||||||
|
return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
SplitButtonLeft.displayName = "SplitButtonLeft";
|
||||||
|
|
||||||
|
const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
|
||||||
|
({ ...props }, ref) => {
|
||||||
|
return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
SplitButtonRight.displayName = "SplitButtonRight";
|
||||||
|
|
||||||
|
const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
ref={ref}
|
||||||
|
orientation="vertical"
|
||||||
|
className={cn("h-full bg-foreground/15", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
SplitButtonSeparator.displayName = "SplitButtonSeparator";
|
||||||
|
|
||||||
|
export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
|
||||||
Reference in New Issue
Block a user