+
+ {tickPositions.minorTicks.map((tick) => (
+
+ ))}
+
+ {tickPositions.majorTicks.map((tick) => (
+
+ ))}
+
+ {tickPositions.majorTicks.map((tick) => (
+
+ {tick.toFixed(1)}
+
+ ))}
+
-
- {animatedProperties.length > 0 ? (
- animatedProperties.map((entry) => {
- const track = entry.track
-
- if (!track) {
- return null
- }
-
- const isFocused = focusedPropertyId === entry.id
-
- return (
-
+
+ {animatedProperties.length > 0 ? (
+ animatedProperties.map((entry) => {
+ const track = entry.track
+
+ if (!track) {
+ return null
+ }
+
+ const isFocused = focusedPropertyId === entry.id
+
+ return (
- {track.keyframes.map((keyframe) => (
-
- ))}
+ key={track.id}
+ style={
+ {
+ "--timeline-track-rgb": hexToRgbChannels(
+ entry.color
+ ),
+ } as CSSProperties
+ }
+ >
+
+ {track.keyframes.map((keyframe) => (
+
+ ))}
+
+ )
+ })
+ ) : (
+
+
+
+ Add your first keyframe from the properties panel.
+
- )
- })
- ) : (
-
-
-
- Add your first keyframe from the properties panel.
-
-
- )}
-
-
-
{
- event.preventDefault()
- event.stopPropagation()
- setDragState({ type: "playhead" })
- }}
- />
+
{
- event.preventDefault()
- event.stopPropagation()
- setDragState({ type: "playhead" })
- }}
- />
+ className={cn(
+ "pointer-events-none absolute top-0 bottom-0 w-0 -translate-x-1/2",
+ dragState?.type === "playhead" &&
+ "[&_div[aria-hidden='true']]:cursor-grabbing"
+ )}
+ style={{ left: `${progress * 100}%` }}
+ >
+
{
+ event.preventDefault()
+ event.stopPropagation()
+ setDragState({ type: "playhead" })
+ }}
+ />
+
{
+ event.preventDefault()
+ event.stopPropagation()
+ setDragState({ type: "playhead" })
+ }}
+ />
+
-
- {selectedTrack ? (
-
{
- event.stopPropagation()
- }}
- >
-
{
- if (value) {
- setTrackInterpolation(
- selectedTrack.id,
- value as TimelineInterpolation
- )
- }
+ {selectedTrack ? (
+ {
+ event.stopPropagation()
}}
- value={selectedTrack.interpolation}
>
- {
- event.stopPropagation()
+ {
+ if (value) {
+ setTrackInterpolation(
+ selectedTrack.id,
+ value as TimelineInterpolation
+ )
+ }
}}
+ value={selectedTrack.interpolation}
>
-
-
-
-
- {
+ event.stopPropagation()
+ }}
>
-
-
- {INTERPOLATION_OPTIONS.map((option) => (
-
-
- {option.label}
-
-
- ))}
-
-
-
-
-
-
- ) : null}
+
+
+
+
+
+
+
+ {INTERPOLATION_OPTIONS.map((option) => (
+
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+
+
+ ) : null}
+
-
-
-
-
-
+
+
+
+ )}
+
)
}
diff --git a/src/components/editor/editor-topbar.tsx b/src/components/editor/editor-topbar.tsx
index dc32675..249a428 100644
--- a/src/components/editor/editor-topbar.tsx
+++ b/src/components/editor/editor-topbar.tsx
@@ -1,15 +1,18 @@
"use client"
import {
- ArrowClockwiseIcon,
- ArrowCounterClockwiseIcon,
- DownloadSimpleIcon,
- GithubLogoIcon,
- MinusIcon,
- PlusIcon,
- StarIcon,
-} from "@phosphor-icons/react"
+ DownloadIcon,
+ DragHandleDots2Icon,
+ GitHubLogoIcon,
+ ResetIcon,
+ StarFilledIcon,
+ ZoomInIcon,
+ ZoomOutIcon,
+} from "@radix-ui/react-icons"
+import { AnimatePresence, motion } from "motion/react"
+import Link from "next/link"
import { useCallback, useEffect, useRef, useState } from "react"
+import { FloatingDesktopPanel } from "@/components/editor/floating-desktop-panel"
import { GlassPanel } from "@/components/ui/glass-panel"
import { IconButton } from "@/components/ui/icon-button"
import { Typography } from "@/components/ui/typography"
@@ -34,7 +37,7 @@ const GITHUB_REPO_URL = "https://github.com/basementstudio/shader-lab"
function GitHubStarLink({ mobile = false }: { mobile?: boolean }) {
return (
-
-
-
+
+
Star
-
+
)
}
@@ -59,6 +62,16 @@ export function EditorTopBar() {
const mobilePanel = useEditorStore((state) => state.mobilePanel)
const zoom = useEditorStore((state) => state.zoom)
const panOffset = useEditorStore((state) => state.panOffset)
+ const hasMovedFloatingPanels = useEditorStore(
+ (state) =>
+ state.floatingPanelsResetting ||
+ Object.values(state.floatingPanels).some(
+ (panel) => panel.x !== 0 || panel.y !== 0
+ )
+ )
+ const resetFloatingPanels = useEditorStore(
+ (state) => state.resetFloatingPanels
+ )
const setPan = useEditorStore((state) => state.setPan)
const setZoom = useEditorStore((state) => state.setZoom)
const resetView = useEditorStore((state) => state.resetView)
@@ -262,76 +275,114 @@ export function EditorTopBar() {
return (
<>
-
({
+ left: Math.max(16, (viewportWidth - panelWidth) / 2),
+ top: 16,
+ })}
>
-
-
-
-
-
applyZoomStep("out")}
- variant="default"
- >
-
-
-
-
applyZoomStep("in")}
- variant="default"
- >
-
-
-
-
setIsExportDialogOpen(true)}
- variant="default"
- >
-
-
-
-
-
-
+ {({ dragHandleProps }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
applyZoomStep("out")}
+ variant="default"
+ >
+
+
+
+
applyZoomStep("in")}
+ variant="default"
+ >
+
+
+
+
+ {hasMovedFloatingPanels ? (
+
+
+
+ ) : null}
+
+
setIsExportDialogOpen(true)}
+ variant="default"
+ >
+
+
+
+
+
+ )}
+
{mobileActionsOpen ? (
@@ -347,7 +398,7 @@ export function EditorTopBar() {
onClick={handleUndo}
variant="default"
>
-
+
-
+
@@ -367,7 +418,7 @@ export function EditorTopBar() {
onClick={() => applyZoomStep("out")}
variant="default"
>
-
+
diff --git a/src/components/editor/floating-desktop-panel.tsx b/src/components/editor/floating-desktop-panel.tsx
new file mode 100644
index 0000000..e898e1d
--- /dev/null
+++ b/src/components/editor/floating-desktop-panel.tsx
@@ -0,0 +1,316 @@
+"use client"
+
+import {
+ type CSSProperties,
+ type ReactNode,
+ type PointerEvent as ReactPointerEvent,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react"
+import { useEditorStore } from "@/store/editor-store"
+
+type FloatingPanelId = "layers" | "properties" | "timeline" | "topbar"
+
+type FloatingDesktopPanelProps = {
+ children: (props: {
+ dragHandleProps: {
+ "data-floating-drag-handle": "true"
+ onPointerDownCapture: (event: ReactPointerEvent