mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||||
import { Timeline } from "@/components/editor/timeline";
|
import { Timeline } from "@/components/editor/panels/timeline";
|
||||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
import { PreviewPanel } from "@/components/editor/panels/preview";
|
||||||
import { EditorHeader } from "@/components/editor/editor-header";
|
import { EditorHeader } from "@/components/editor/editor-header";
|
||||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||||
@@ -65,7 +65,7 @@ function EditorLayout() {
|
|||||||
defaultSize={panels.tools}
|
defaultSize={panels.tools}
|
||||||
minSize={15}
|
minSize={15}
|
||||||
maxSize={40}
|
maxSize={40}
|
||||||
className="min-w-0 rounded-sm"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
<AssetsPanel />
|
<AssetsPanel />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -86,7 +86,7 @@ function EditorLayout() {
|
|||||||
defaultSize={panels.properties}
|
defaultSize={panels.properties}
|
||||||
minSize={15}
|
minSize={15}
|
||||||
maxSize={40}
|
maxSize={40}
|
||||||
className="min-w-0 rounded-sm"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
<PropertiesPanel />
|
<PropertiesPanel />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
+230
-202
@@ -8,88 +8,115 @@
|
|||||||
@plugin "tailwindcss-animate";
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: hsl(0, 0%, 100%);
|
--background: hsl(0, 0%, 100%);
|
||||||
--foreground: hsl(0 0% 11%);
|
--foreground: hsl(0 0% 11%);
|
||||||
--card: hsl(0, 0%, 100%);
|
--card: hsl(0, 0%, 100%);
|
||||||
--card-foreground: hsl(0 0% 11%);
|
--card-foreground: hsl(0 0% 11%);
|
||||||
--popover: hsl(0, 0%, 100%);
|
--popover: hsl(0, 0%, 100%);
|
||||||
--popover-foreground: hsl(0 0% 2%);
|
--popover-hover: hsl(0, 0%, 96%);
|
||||||
--primary: hsl(203, 100%, 50%);
|
--popover-foreground: hsl(0 0% 2%);
|
||||||
--primary-hover: hsl(203, 100%, 45%);
|
--primary: #009dff;
|
||||||
--primary-foreground: hsl(0, 0%, 100%);
|
--primary-foreground: hsl(0, 0%, 100%);
|
||||||
--secondary: hsl(216 13% 94%);
|
--secondary: hsl(204, 100%, 97%);
|
||||||
--secondary-foreground: hsl(0 0% 2%);
|
--secondary-border: hsl(204, 100%, 94%);
|
||||||
--muted: hsl(0 0% 85.1%);
|
--secondary-foreground: hsl(200, 98%, 39%);
|
||||||
--muted-foreground: hsl(0 0% 50%);
|
--muted: hsl(0 0% 85.1%);
|
||||||
--accent: hsl(216, 13%, 88%);
|
--muted-foreground: hsl(0 0% 50%);
|
||||||
--accent-foreground: hsl(0 0% 2%);
|
--accent: hsl(0, 0%, 96%);
|
||||||
--destructive: hsl(0, 83%, 50%);
|
--accent-foreground: hsl(0 0% 2%);
|
||||||
--destructive-foreground: hsl(0, 0%, 100%);
|
--destructive: hsl(0, 83%, 50%);
|
||||||
--constructive: hsl(141, 71%, 48%);
|
--destructive-foreground: hsl(0, 0%, 100%);
|
||||||
--constructive-foreground: hsl(0, 0%, 100%);
|
--constructive: hsl(141, 71%, 48%);
|
||||||
--border: hsl(0 0% 88%);
|
--constructive-foreground: hsl(0, 0%, 100%);
|
||||||
--input: hsl(0 0% 85.1%);
|
--border: hsl(0 0% 91%);
|
||||||
--ring: hsl(0, 0%, 55%);
|
--input: hsl(0 0% 85.1%);
|
||||||
--chart-1: hsl(220 70% 50%);
|
--ring: hsl(0, 0%, 55%);
|
||||||
--chart-2: hsl(160 60% 45%);
|
--chart-1: hsl(220 70% 50%);
|
||||||
--chart-3: hsl(30 80% 55%);
|
--chart-2: hsl(160 60% 45%);
|
||||||
--chart-4: hsl(280 65% 60%);
|
--chart-3: hsl(30 80% 55%);
|
||||||
--chart-5: hsl(340 75% 55%);
|
--chart-4: hsl(280 65% 60%);
|
||||||
--sidebar-background: hsl(0 0% 96.1%);
|
--chart-5: hsl(340 75% 55%);
|
||||||
--sidebar-foreground: hsl(0 0% 2%);
|
--sidebar-background: hsl(0 0% 96.1%);
|
||||||
--sidebar-primary: hsl(0 0% 2%);
|
--sidebar-foreground: hsl(0 0% 2%);
|
||||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
--sidebar-primary: hsl(0 0% 2%);
|
||||||
--sidebar-accent: hsl(0 0% 85.1%);
|
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
--sidebar-accent: hsl(0 0% 85.1%);
|
||||||
--sidebar-border: hsl(0 0% 85.1%);
|
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||||
--sidebar-ring: hsl(0 0% 16.9%);
|
--sidebar-border: hsl(0 0% 85.1%);
|
||||||
--panel-background: hsl(216 13% 94%);
|
--sidebar-ring: hsl(0 0% 16.9%);
|
||||||
--panel-accent: hsl(216, 8%, 88%);
|
--sidebar: hsl(0 0% 98%);
|
||||||
--sidebar: hsl(0 0% 98%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
--background: hsl(216 13% 98%);
|
||||||
|
--foreground: hsl(0 0% 13%);
|
||||||
|
--card: hsl(0, 0%, 98%);
|
||||||
|
--card-foreground: hsl(0 0% 13%);
|
||||||
|
--primary-foreground: hsl(0, 0%, 98%);
|
||||||
|
--secondary: hsl(204, 100%, 95%);
|
||||||
|
--secondary-border: hsl(204, 100%, 92%);
|
||||||
|
--secondary-foreground: hsl(200, 98%, 37%);
|
||||||
|
--muted: hsl(0 0% 83.1%);
|
||||||
|
--muted-foreground: hsl(0 0% 48%);
|
||||||
|
--accent: hsl(0, 0%, 93%);
|
||||||
|
--accent-foreground: hsl(0 0% 5%);
|
||||||
|
--destructive: hsl(0, 83%, 50%);
|
||||||
|
--destructive-foreground: hsl(0, 0%, 98%);
|
||||||
|
--constructive: hsl(141, 71%, 48%);
|
||||||
|
--constructive-foreground: hsl(0, 0%, 98%);
|
||||||
|
--border: hsl(0 0% 89%);
|
||||||
|
--input: hsl(0 0% 83.1%);
|
||||||
|
--ring: hsl(0, 0%, 53%);
|
||||||
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: hsl(0 0% 4%);
|
--background: hsl(0, 0%, 7%);
|
||||||
--foreground: hsl(0 0% 89%);
|
--foreground: hsl(0 0% 87%);
|
||||||
--card: hsl(0 0% 4%);
|
--card: hsl(0, 0%, 7%);
|
||||||
--card-foreground: hsl(0 0% 89%);
|
--card-foreground: hsl(0 0% 87%);
|
||||||
--popover: hsl(0 0% 14.9%);
|
--popover: hsl(0, 0%, 16%);
|
||||||
--popover-foreground: hsl(0 0% 98%);
|
--popover-hover: hsl(0, 0%, 22%);
|
||||||
--primary: hsl(203, 100%, 50%);
|
--popover-foreground: hsl(0 0% 95%);
|
||||||
--primary-hover: hsl(203, 100%, 45%);
|
--secondary: hsl(204, 100%, 12%);
|
||||||
--primary-foreground: hsl(0 0% 9%);
|
--secondary-border: hsl(204, 100%, 15%);
|
||||||
--secondary: hsl(0 0% 14.9%);
|
--secondary-foreground: hsl(200, 98%, 61%);
|
||||||
--secondary-foreground: hsl(0 0% 98%);
|
--muted: hsl(0 0% 20%);
|
||||||
--muted: hsl(0 0% 14.9%);
|
--accent: hsl(0, 0%, 14%);
|
||||||
--muted-foreground: hsl(0 0% 63.9%);
|
--accent-foreground: hsl(0 0% 95%);
|
||||||
--accent: hsl(0, 0%, 28%);
|
--constructive: hsl(141, 71%, 52%);
|
||||||
--accent-foreground: hsl(0 0% 98%);
|
--border: hsl(0 0% 16%);
|
||||||
--destructive: hsl(0 83%, 55%);
|
--input: hsl(0 0% 20%);
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
--ring: hsl(0, 0%, 50%);
|
||||||
--constructive: hsl(141, 71%, 48%);
|
--sidebar-background: hsl(0 0% 8%);
|
||||||
--constructive-foreground: hsl(0 0% 100%);
|
--sidebar-foreground: hsl(0 0% 95%);
|
||||||
--border: hsl(0 0% 17%);
|
--sidebar-primary: hsl(0 0% 95%);
|
||||||
--input: hsl(0 0% 14.9%);
|
--sidebar-primary-foreground: hsl(0 0% 15%);
|
||||||
--ring: hsl(0 0% 83.1%);
|
--sidebar-accent: hsl(0 0% 20%);
|
||||||
--chart-1: hsl(220 70% 50%);
|
--sidebar-accent-foreground: hsl(0 0% 95%);
|
||||||
--chart-2: hsl(160 60% 45%);
|
--sidebar-border: hsl(0 0% 20%);
|
||||||
--chart-3: hsl(30 80% 55%);
|
--sidebar-ring: hsl(0 0% 83.1%);
|
||||||
--chart-4: hsl(280 65% 60%);
|
--sidebar: hsl(0 0% 6%);
|
||||||
--chart-5: hsl(340 75% 55%);
|
}
|
||||||
--sidebar-background: hsl(0 0% 3.9%);
|
|
||||||
--sidebar-foreground: hsl(0 0% 98%);
|
.dark .panel {
|
||||||
--sidebar-primary: hsl(0 0% 98%);
|
--background: hsl(0 0% 10%);
|
||||||
--sidebar-primary-foreground: hsl(0 0% 9%);
|
--foreground: hsl(0 0% 85%);
|
||||||
--sidebar-accent: hsl(0 0% 14.9%);
|
--card: hsl(0, 0%, 10%);
|
||||||
--sidebar-accent-foreground: hsl(0 0% 98%);
|
--card-foreground: hsl(0 0% 85%);
|
||||||
--sidebar-border: hsl(0 0% 14.9%);
|
--secondary: hsl(204, 100%, 12%);
|
||||||
--sidebar-ring: hsl(0 0% 83.1%);
|
--secondary-border: hsl(204, 100%, 17%);
|
||||||
--panel-background: hsl(0 0% 11%);
|
--secondary-foreground: hsl(200, 98%, 63%);
|
||||||
--panel-accent: hsl(0 0% 15%);
|
--muted: hsl(0 0% 22%);
|
||||||
--sidebar: hsl(240 5.9% 10%);
|
--accent: hsl(0, 0%, 15%);
|
||||||
|
--accent-foreground: hsl(0 0% 93%);
|
||||||
|
--constructive: hsl(141, 71%, 52%);
|
||||||
|
--border: hsl(0 0% 18%);
|
||||||
|
--input: hsl(0 0% 22%);
|
||||||
|
--ring: hsl(0, 0%, 52%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
/*
|
/*
|
||||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
so we've added these compatibility styles to make sure everything still
|
so we've added these compatibility styles to make sure everything still
|
||||||
looks the same as it did with Tailwind CSS v3.
|
looks the same as it did with Tailwind CSS v3.
|
||||||
@@ -97,165 +124,166 @@
|
|||||||
If we ever want to remove these styles, we need to add an explicit border
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
color utility to any element that depends on these defaults.
|
color utility to any element that depends on these defaults.
|
||||||
*/
|
*/
|
||||||
*,
|
*,
|
||||||
::after,
|
::after,
|
||||||
::before,
|
::before,
|
||||||
::backdrop,
|
::backdrop,
|
||||||
::file-selector-button {
|
::file-selector-button {
|
||||||
border-color: var(--color-gray-200, currentcolor);
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
}
|
}
|
||||||
/* Other default base styles */
|
/* Other default base styles */
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
/* Prevent back/forward swipe */
|
/* Prevent back/forward swipe */
|
||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
}
|
}
|
||||||
|
::selection {
|
||||||
|
@apply bg-primary/35 selection:text-primary-foreground;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
/* Responsive breakpoints */
|
/* Responsive breakpoints */
|
||||||
--breakpoint-xs: 30rem;
|
--breakpoint-xs: 30rem;
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
--font-sans: var(--font-inter), sans-serif;
|
--font-sans: var(--font-inter), sans-serif;
|
||||||
|
|
||||||
/* Font sizes */
|
/* Font sizes */
|
||||||
--text-xl: 1.20rem;
|
--text-xl: 1.2rem;
|
||||||
--text-base: 0.92rem;
|
--text-base: 0.92rem;
|
||||||
--text-base--line-height: calc(1.5 / 0.95);
|
--text-base--line-height: calc(1.5 / 0.95);
|
||||||
--text-xs: 0.75rem;
|
--text-xs: 0.75rem;
|
||||||
--text-xs--line-height: calc(1 / 0.8);
|
--text-sm: 0.85rem;
|
||||||
|
--text-xs--line-height: calc(1 / 0.8);
|
||||||
|
|
||||||
/* Border radius */
|
/* Border radius */
|
||||||
--radius-lg: 0.82rem;
|
--radius-lg: 0.82rem;
|
||||||
--radius-md: 0.65rem;
|
--radius-md: 0.65rem;
|
||||||
--radius-sm: 0.35rem;
|
--radius-sm: 0.35rem;
|
||||||
|
|
||||||
/* Palette mapped to root design tokens */
|
/* Palette mapped to root design tokens */
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-hover: var(--popover-hover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-primary-hover: var(--primary-hover);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary-border: var(--secondary-border);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
--color-constructive: var(--constructive);
|
--color-constructive: var(--constructive);
|
||||||
--color-constructive-foreground: var(--constructive-foreground);
|
--color-constructive-foreground: var(--constructive-foreground);
|
||||||
|
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
/* Chart colors */
|
/* Chart colors */
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
--color-sidebar: var(--sidebar-background);
|
--color-sidebar: var(--sidebar-background);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
/* Panel */
|
/* Animations */
|
||||||
--color-panel: var(--panel-background);
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
--color-panel-accent: var(--panel-accent);
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
|
|
||||||
/* Animations */
|
@keyframes accordion-down {
|
||||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
from {
|
||||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes accordion-down {
|
@keyframes accordion-up {
|
||||||
from {
|
from {
|
||||||
height: 0;
|
height: var(--radix-accordion-content-height);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
height: var(--radix-accordion-content-height);
|
height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes accordion-up {
|
|
||||||
from {
|
|
||||||
height: var(--radix-accordion-content-height);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility scrollbar-hidden {
|
@utility scrollbar-hidden {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility scrollbar-x-hidden {
|
@utility scrollbar-x-hidden {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
&::-webkit-scrollbar:horizontal {
|
&::-webkit-scrollbar:horizontal {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility scrollbar-y-hidden {
|
@utility scrollbar-y-hidden {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
&::-webkit-scrollbar:vertical {
|
&::-webkit-scrollbar:vertical {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility scrollbar-thin {
|
@utility scrollbar-thin {
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
&::-webkit-scrollbar-track {
|
&::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
&::-webkit-scrollbar-thumb:hover {
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--muted-foreground);
|
background: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ const formatProjectDuration = ({
|
|||||||
return formatTimeCode({ timeInSeconds: duration, format });
|
return formatTimeCode({ timeInSeconds: duration, format });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VIEW_MODE_OPTIONS = [
|
||||||
|
{ mode: "grid" as const, icon: GridViewIcon, label: "Grid view" },
|
||||||
|
{ mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
|
const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
@@ -147,34 +152,23 @@ function ProjectsHeader() {
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
|
||||||
<div className="hidden md:flex rounded-md border p-1 h-10">
|
<div className="hidden md:flex items-center rounded-md border p-1 px-1.5 h-10">
|
||||||
<button
|
{VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
|
||||||
type="button"
|
<Button
|
||||||
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`}
|
key={mode}
|
||||||
onClick={() => setViewMode({ viewMode: "grid" })}
|
variant="ghost"
|
||||||
onKeyDown={(event) =>
|
size="icon"
|
||||||
event.key === "Enter" && setViewMode({ viewMode: "grid" })
|
className={cn(
|
||||||
}
|
"rounded-sm hover:bg-background",
|
||||||
aria-label="Grid view"
|
isHydrated && viewMode === mode && "!bg-accent",
|
||||||
aria-pressed={isHydrated && viewMode === "grid"}
|
)}
|
||||||
>
|
onClick={() => setViewMode({ viewMode: mode })}
|
||||||
<HugeiconsIcon icon={GridViewIcon} className="size-4" />
|
aria-label={label}
|
||||||
</button>
|
aria-pressed={isHydrated && viewMode === mode}
|
||||||
<button
|
>
|
||||||
type="button"
|
<HugeiconsIcon icon={icon} className="size-4" />
|
||||||
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`}
|
</Button>
|
||||||
onClick={() => setViewMode({ viewMode: "list" })}
|
))}
|
||||||
onKeyDown={(event) =>
|
|
||||||
event.key === "Enter" && setViewMode({ viewMode: "list" })
|
|
||||||
}
|
|
||||||
aria-label="List view"
|
|
||||||
aria-pressed={isHydrated && viewMode === "list"}
|
|
||||||
>
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={LeftToRightListDashIcon}
|
|
||||||
className="size-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -277,28 +271,21 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
|
|||||||
<div className="h-4 w-px bg-border/50 block md:hidden" />
|
<div className="h-4 w-px bg-border/50 block md:hidden" />
|
||||||
|
|
||||||
<div className="flex md:hidden items-center gap-4">
|
<div className="flex md:hidden items-center gap-4">
|
||||||
<Button
|
{VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
|
||||||
variant="text"
|
<Button
|
||||||
onClick={() => setViewMode({ viewMode: "grid" })}
|
key={mode}
|
||||||
>
|
variant="text"
|
||||||
<HugeiconsIcon
|
onClick={() => setViewMode({ viewMode: mode })}
|
||||||
icon={GridViewIcon}
|
aria-label={label}
|
||||||
className={cn(
|
>
|
||||||
viewMode === "grid" ? "text-primary" : "text-muted-foreground",
|
<HugeiconsIcon
|
||||||
)}
|
icon={icon}
|
||||||
/>
|
className={cn(
|
||||||
</Button>
|
viewMode === mode ? "text-primary" : "text-muted-foreground",
|
||||||
<Button
|
)}
|
||||||
variant="text"
|
/>
|
||||||
onClick={() => setViewMode({ viewMode: "list" })}
|
</Button>
|
||||||
>
|
))}
|
||||||
<HugeiconsIcon
|
|
||||||
icon={LeftToRightListDashIcon}
|
|
||||||
className={cn(
|
|
||||||
viewMode === "list" ? "text-primary" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedProjectCount > 0 ? <ProjectActions /> : null}
|
{selectedProjectCount > 0 ? <ProjectActions /> : null}
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ export function EditableTimecode({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-none bg-transparent font-mono text-xs outline-none",
|
"-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
|
||||||
"focus:bg-background focus:border-primary focus:rounded focus:border focus:px-1",
|
"focus:bg-background focus:border-primary focus:rounded",
|
||||||
"text-primary tabular-nums",
|
"text-primary tabular-nums",
|
||||||
hasError && "text-destructive focus:border-destructive",
|
hasError && "text-destructive focus:border-destructive",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { useRef, useState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -17,23 +16,21 @@ import { useRouter } from "next/navigation";
|
|||||||
import { FaDiscord } from "react-icons/fa6";
|
import { FaDiscord } from "react-icons/fa6";
|
||||||
import { ExportButton } from "./export-button";
|
import { ExportButton } from "./export-button";
|
||||||
import { ThemeToggle } from "../theme-toggle";
|
import { ThemeToggle } from "../theme-toggle";
|
||||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import {
|
import { ArrowLeft02Icon, CommandIcon } from "@hugeicons/core-free-icons";
|
||||||
ArrowLeft02Icon,
|
|
||||||
Edit03Icon,
|
|
||||||
Delete02Icon,
|
|
||||||
CommandIcon,
|
|
||||||
} from "@hugeicons/core-free-icons";
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
|
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
export function EditorHeader() {
|
export function EditorHeader() {
|
||||||
return (
|
return (
|
||||||
<header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5">
|
<header className="bg-background flex h-[3.4rem] items-center justify-between px-3 pt-0.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<ProjectDropdown />
|
<ProjectDropdown />
|
||||||
|
<EditableProjectName />
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-2">
|
<nav className="flex items-center gap-2">
|
||||||
<ExportButton />
|
<ExportButton />
|
||||||
@@ -111,14 +108,14 @@ function ProjectDropdown() {
|
|||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="p-1 rounded-sm size-8">
|
||||||
variant="secondary"
|
<Image
|
||||||
className="flex h-auto items-center justify-center px-2.5 py-1.5"
|
src={DEFAULT_LOGO_URL}
|
||||||
>
|
alt="Project thumbnail"
|
||||||
<ChevronDown className="text-muted-foreground" />
|
width={32}
|
||||||
<span className="mr-2 text-[0.85rem]">
|
height={32}
|
||||||
{activeProject?.metadata.name}
|
className="invert dark:invert-0 size-5"
|
||||||
</span>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="z-100 w-52">
|
<DropdownMenuContent align="start" className="z-100 w-52">
|
||||||
@@ -130,21 +127,7 @@ function ProjectDropdown() {
|
|||||||
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
|
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
|
||||||
Exit project
|
Exit project
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
onClick={() => setOpenDialog("rename")}
|
|
||||||
>
|
|
||||||
<HugeiconsIcon icon={Edit03Icon} className="size-4" />
|
|
||||||
Rename project
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
onClick={() => setOpenDialog("delete")}
|
|
||||||
>
|
|
||||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
|
||||||
Delete project
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex items-center gap-1.5"
|
className="flex items-center gap-1.5"
|
||||||
@@ -185,3 +168,79 @@ function ProjectDropdown() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditableProjectName() {
|
||||||
|
const editor = useEditor();
|
||||||
|
const activeProject = editor.project.getActive();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const originalNameRef = useRef("");
|
||||||
|
|
||||||
|
const projectName = activeProject?.metadata.name || "";
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
if (isEditing) return;
|
||||||
|
originalNameRef.current = projectName;
|
||||||
|
setIsEditing(true);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.select();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!inputRef.current || !activeProject) return;
|
||||||
|
const newName = inputRef.current.value.trim();
|
||||||
|
setIsEditing(false);
|
||||||
|
|
||||||
|
if (!newName) {
|
||||||
|
inputRef.current.value = originalNameRef.current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newName !== originalNameRef.current) {
|
||||||
|
try {
|
||||||
|
await editor.project.renameProject({
|
||||||
|
id: activeProject.metadata.id,
|
||||||
|
name: newName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to rename project", {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "Please try again",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
inputRef.current?.blur();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = originalNameRef.current;
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
defaultValue={projectName}
|
||||||
|
readOnly={!isEditing}
|
||||||
|
onClick={startEditing}
|
||||||
|
onBlur={saveEdit}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{ fieldSizing: "content" }}
|
||||||
|
className={cn(
|
||||||
|
"text-[0.9rem] h-8 px-2 py-1 rounded-sm bg-transparent outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||||
|
isEditing && "ring-1 ring-ring cursor-text hover:bg-transparent",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Progress } from "@/components/ui/progress";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
|
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
|
||||||
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
|
import { Check, Copy, Download, RotateCcw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
EXPORT_FORMAT_VALUES,
|
EXPORT_FORMAT_VALUES,
|
||||||
EXPORT_QUALITY_VALUES,
|
EXPORT_QUALITY_VALUES,
|
||||||
@@ -44,9 +44,7 @@ export function ExportButton() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
|
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
|
||||||
hasProject
|
hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
|
||||||
? "cursor-pointer"
|
|
||||||
: "cursor-not-allowed opacity-50",
|
|
||||||
)}
|
)}
|
||||||
onClick={hasProject ? handleExport : undefined}
|
onClick={hasProject ? handleExport : undefined}
|
||||||
disabled={!hasProject}
|
disabled={!hasProject}
|
||||||
@@ -141,20 +139,12 @@ function ExportPopover({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (!isExporting) {
|
|
||||||
onOpenChange(false);
|
|
||||||
setExportResult(null);
|
|
||||||
setProgress(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
cancelRequestedRef.current = true;
|
cancelRequestedRef.current = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
|
<PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
|
||||||
{exportResult && !exportResult.success ? (
|
{exportResult && !exportResult.success ? (
|
||||||
<ExportError
|
<ExportError
|
||||||
error={exportResult.error || "Unknown error occurred"}
|
error={exportResult.error || "Unknown error occurred"}
|
||||||
@@ -162,23 +152,20 @@ function ExportPopover({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between p-3 border-b">
|
||||||
<h3 className="font-medium">
|
<h3 className="font-medium text-sm">
|
||||||
{isExporting ? "Exporting project" : "Export project"}
|
{isExporting ? "Exporting project" : "Export project"}
|
||||||
</h3>
|
</h3>
|
||||||
<Button variant="text" size="icon" onClick={handleClose}>
|
|
||||||
<X className="text-foreground/85 !size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{!isExporting && (
|
{!isExporting && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col">
|
||||||
<PropertyGroup
|
<PropertyGroup
|
||||||
title="Format"
|
title="Format"
|
||||||
titleClassName="text-sm"
|
|
||||||
defaultExpanded={false}
|
defaultExpanded={false}
|
||||||
|
hasBorderTop={false}
|
||||||
>
|
>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={format}
|
value={format}
|
||||||
@@ -203,11 +190,7 @@ function ExportPopover({
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup
|
<PropertyGroup title="Quality" defaultExpanded={false}>
|
||||||
title="Quality"
|
|
||||||
titleClassName="text-sm"
|
|
||||||
defaultExpanded={false}
|
|
||||||
>
|
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={quality}
|
value={quality}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -237,11 +220,7 @@ function ExportPopover({
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup
|
<PropertyGroup title="Audio" defaultExpanded={false}>
|
||||||
title="Audio"
|
|
||||||
titleClassName="text-sm"
|
|
||||||
defaultExpanded={false}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="include-audio"
|
id="include-audio"
|
||||||
@@ -257,15 +236,17 @@ function ExportPopover({
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleExport} className="w-full gap-2">
|
<div className="p-3 pt-0">
|
||||||
<Download className="size-4" />
|
<Button onClick={handleExport} className="w-full gap-2">
|
||||||
Export
|
<Download className="size-4" />
|
||||||
</Button>
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isExporting && (
|
{isExporting && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 p-3">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center justify-between text-center">
|
<div className="flex items-center justify-between text-center">
|
||||||
<p className="text-muted-foreground mb-2 text-sm">
|
<p className="text-muted-foreground mb-2 text-sm">
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function DraggableItem({
|
|||||||
<AspectRatio
|
<AspectRatio
|
||||||
ratio={aspectRatio}
|
ratio={aspectRatio}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-panel-accent relative overflow-hidden",
|
"bg-accent relative overflow-hidden",
|
||||||
isRounded && "rounded-sm",
|
isRounded && "rounded-sm",
|
||||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||||
)}
|
)}
|
||||||
@@ -215,7 +215,7 @@ function PlusButton({
|
|||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background hover:bg-panel text-foreground absolute right-2 bottom-2 size-5",
|
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function AssetsPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel flex h-full">
|
<div className="panel bg-background flex h-full rounded-sm border overflow-hidden">
|
||||||
<TabBar />
|
<TabBar />
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
|
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import {
|
import {
|
||||||
TAB_KEYS,
|
TAB_KEYS,
|
||||||
@@ -15,9 +16,9 @@ import {
|
|||||||
|
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const { activeTab, setActiveTab } = useAssetsPanelStore();
|
const { activeTab, setActiveTab } = useAssetsPanelStore();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [showTopFade, setShowTopFade] = useState(false);
|
const [showTopFade, setShowTopFade] = useState(false);
|
||||||
const [showBottomFade, setShowBottomFade] = useState(false);
|
const [showBottomFade, setShowBottomFade] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const checkScrollPosition = useCallback(() => {
|
const checkScrollPosition = useCallback(() => {
|
||||||
const element = scrollRef.current;
|
const element = scrollRef.current;
|
||||||
@@ -48,32 +49,24 @@ export function TabBar() {
|
|||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4"
|
className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto"
|
||||||
>
|
>
|
||||||
{TAB_KEYS.map((tabKey) => {
|
{TAB_KEYS.map((tabKey) => {
|
||||||
const tab = tabs[tabKey];
|
const tab = tabs[tabKey];
|
||||||
return (
|
return (
|
||||||
<Tooltip key={tabKey} delayDuration={10}>
|
<Tooltip key={tabKey} delayDuration={10}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant={activeTab === tabKey ? "secondary" : "text"}
|
||||||
aria-label={tab.label}
|
aria-label={tab.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75",
|
"flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
|
||||||
activeTab === tabKey
|
activeTab !== tabKey && "border border-transparent text-muted-foreground",
|
||||||
? "text-primary !opacity-100"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => setActiveTab(tabKey)}
|
onClick={() => setActiveTab(tabKey)}
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
setActiveTab(tabKey);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<tab.icon className=" " />
|
<tab.icon />
|
||||||
</button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="right"
|
side="right"
|
||||||
@@ -108,8 +101,8 @@ function FadeOverlay({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute right-0 left-0 h-6",
|
"pointer-events-none absolute right-0 left-0 h-6",
|
||||||
direction === "top" && show
|
direction === "top" && show
|
||||||
? "from-panel top-0 bg-gradient-to-b to-transparent"
|
? "from-background top-0 bg-gradient-to-b to-transparent"
|
||||||
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
|
: "from-background bottom-0 bg-gradient-to-t to-transparent",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
|
|
||||||
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -21,6 +20,7 @@ import { transcriptionService } from "@/services/transcription/service";
|
|||||||
import { decodeAudioToFloat32 } from "@/lib/media/audio";
|
import { decodeAudioToFloat32 } from "@/lib/media/audio";
|
||||||
import { buildCaptionChunks } from "@/lib/transcription/caption";
|
import { buildCaptionChunks } from "@/lib/transcription/caption";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
export function Captions() {
|
export function Captions() {
|
||||||
const [selectedLanguage, setSelectedLanguage] =
|
const [selectedLanguage, setSelectedLanguage] =
|
||||||
@@ -112,12 +112,13 @@ export function Captions() {
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex h-full flex-col justify-between"
|
className="flex h-full flex-col justify-between"
|
||||||
>
|
>
|
||||||
<PropertyGroup title="Language">
|
<div className="flex flex-col gap-3">
|
||||||
|
<Label>Language</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedLanguage}
|
value={selectedLanguage}
|
||||||
onValueChange={(value) => handleLanguageChange({ value })}
|
onValueChange={(value) => handleLanguageChange({ value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-panel-accent h-8 w-full text-xs">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a language" />
|
<SelectValue placeholder="Select a language" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -129,7 +130,7 @@ export function Captions() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</PropertyGroup>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function MediaView() {
|
|||||||
className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
|
className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
>
|
>
|
||||||
<div className="bg-panel py-2 px-4 flex items-center justify-between border-b">
|
<div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b">
|
||||||
<span className="text-muted-foreground text-sm">Assets</span>
|
<span className="text-muted-foreground text-sm">Assets</span>
|
||||||
<div className="flex items-center gap-0">
|
<div className="flex items-center gap-0">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -320,10 +320,10 @@ export function MediaView() {
|
|||||||
onClick={openFilePicker}
|
onClick={openFilePicker}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="items-center justify-center gap-1.5 ml-1.5"
|
className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3"
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||||
Upload
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { colors } from "@/data/colors/solid";
|
|||||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { TProject } from "@/types/project";
|
|
||||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import {
|
import {
|
||||||
@@ -30,8 +29,7 @@ import {
|
|||||||
PropertyItemLabel,
|
PropertyItemLabel,
|
||||||
PropertyItemValue,
|
PropertyItemValue,
|
||||||
} from "@/components/editor/panels/properties/property-item";
|
} from "@/components/editor/panels/properties/property-item";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { ColorPicker } from "@/components/ui/color-picker";
|
||||||
import { DropperIcon } from "@hugeicons/core-free-icons";
|
|
||||||
|
|
||||||
export function SettingsView() {
|
export function SettingsView() {
|
||||||
return <ProjectSettingsTabs />;
|
return <ProjectSettingsTabs />;
|
||||||
@@ -56,24 +54,9 @@ function ProjectSettingsTabs() {
|
|||||||
label: "Background",
|
label: "Background",
|
||||||
content: (
|
content: (
|
||||||
<div className="flex h-full flex-col justify-between">
|
<div className="flex h-full flex-col justify-between">
|
||||||
<div className="flex-1 p-5">
|
<div className="flex-1">
|
||||||
<BackgroundView />
|
<BackgroundView />
|
||||||
</div>
|
</div>
|
||||||
{/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg">
|
|
||||||
<Separator />
|
|
||||||
<Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none">
|
|
||||||
Custom background
|
|
||||||
<PlusIcon />
|
|
||||||
</Button>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{/* another ui */}
|
|
||||||
{/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
|
|
||||||
<Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
|
|
||||||
<span className="text-sm">Custom</span>
|
|
||||||
<PlusIcon className="" />
|
|
||||||
</Button>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -83,15 +66,6 @@ function ProjectSettingsTabs() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) {
|
|
||||||
const { canvasSize } = activeProject.settings;
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectInfoView() {
|
function ProjectInfoView() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
@@ -117,7 +91,7 @@ function ProjectInfoView() {
|
|||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentCanvasSize = getCurrentCanvasSize({ activeProject });
|
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||||
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
|
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
|
||||||
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
|
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
|
||||||
const presetIndex = findPresetIndexByAspectRatio({
|
const presetIndex = findPresetIndexByAspectRatio({
|
||||||
@@ -162,7 +136,7 @@ function ProjectInfoView() {
|
|||||||
value={selectedPresetValue}
|
value={selectedPresetValue}
|
||||||
onValueChange={(value) => handleAspectRatioChange({ value })}
|
onValueChange={(value) => handleAspectRatioChange({ value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-panel-accent">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select an aspect ratio" />
|
<SelectValue placeholder="Select an aspect ratio" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -190,7 +164,7 @@ function ProjectInfoView() {
|
|||||||
value={activeProject.settings.fps.toString()}
|
value={activeProject.settings.fps.toString()}
|
||||||
onValueChange={handleFpsChange}
|
onValueChange={handleFpsChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-panel-accent">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a frame rate" />
|
<SelectValue placeholder="Select a frame rate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -219,7 +193,7 @@ const BlurPreview = memo(
|
|||||||
}) => (
|
}) => (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border",
|
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||||
isSelected && "border-primary border-2",
|
isSelected && "border-primary border-2",
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
@@ -265,7 +239,7 @@ const BackgroundPreviews = memo(
|
|||||||
<button
|
<button
|
||||||
key={`${index}-${bg}`}
|
key={`${index}-${bg}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border",
|
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||||
isColorBackground &&
|
isColorBackground &&
|
||||||
bg === currentBackgroundColor &&
|
bg === currentBackgroundColor &&
|
||||||
"border-primary border-2",
|
"border-primary border-2",
|
||||||
@@ -347,48 +321,35 @@ function BackgroundView() {
|
|||||||
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const backgroundSections = [
|
||||||
|
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
|
||||||
|
{ title: "Pattern craft", backgrounds: patternCraftGradients },
|
||||||
|
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-4">
|
<div className="flex h-full flex-col">
|
||||||
<PropertyGroup title="Blur" defaultExpanded={false}>
|
<PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
|
||||||
<div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div>
|
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup title="Colors" defaultExpanded={false}>
|
{backgroundSections.map((section) => (
|
||||||
<div className="grid w-full grid-cols-4 gap-2">
|
<PropertyGroup
|
||||||
<div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border">
|
key={section.title}
|
||||||
<HugeiconsIcon icon={DropperIcon} className="size-4" />
|
title={section.title}
|
||||||
|
defaultExpanded={false}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<BackgroundPreviews
|
||||||
|
backgrounds={section.backgrounds}
|
||||||
|
currentBackgroundColor={currentBackgroundColor}
|
||||||
|
isColorBackground={isColorBackground}
|
||||||
|
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||||
|
useBackgroundColor={section.useBackgroundColor}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<BackgroundPreviews
|
</PropertyGroup>
|
||||||
backgrounds={colors}
|
))}
|
||||||
currentBackgroundColor={currentBackgroundColor}
|
|
||||||
isColorBackground={isColorBackground}
|
|
||||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
|
||||||
useBackgroundColor={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup title="Pattern craft" defaultExpanded={false}>
|
|
||||||
<div className="grid w-full grid-cols-4 gap-2">
|
|
||||||
<BackgroundPreviews
|
|
||||||
backgrounds={patternCraftGradients}
|
|
||||||
currentBackgroundColor={currentBackgroundColor}
|
|
||||||
isColorBackground={isColorBackground}
|
|
||||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup title="Syntax UI" defaultExpanded={false}>
|
|
||||||
<div className="grid w-full grid-cols-4 gap-2">
|
|
||||||
<BackgroundPreviews
|
|
||||||
backgrounds={syntaxUIGradients}
|
|
||||||
currentBackgroundColor={currentBackgroundColor}
|
|
||||||
isColorBackground={isColorBackground}
|
|
||||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PropertyGroup>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ function SoundEffectsView() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search sound effects"
|
placeholder="Search sound effects"
|
||||||
className="bg-panel-accent w-full"
|
className="bg-accent w-full"
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={({ currentTarget }) =>
|
onChange={({ currentTarget }) =>
|
||||||
@@ -406,7 +406,7 @@ function SavedSoundsView() {
|
|||||||
|
|
||||||
if (savedSounds.length === 0) {
|
if (savedSounds.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon
|
||||||
icon={FavouriteIcon}
|
icon={FavouriteIcon}
|
||||||
className="text-muted-foreground size-10"
|
className="text-muted-foreground size-10"
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ function CollectionGrid({
|
|||||||
|
|
||||||
function EmptyView({ message }: { message: string }) {
|
function EmptyView({ message }: { message: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon
|
||||||
icon={HappyIcon}
|
icon={HappyIcon}
|
||||||
className="text-muted-foreground size-10"
|
className="text-muted-foreground size-10"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function TextView() {
|
|||||||
<DraggableItem
|
<DraggableItem
|
||||||
name="Default text"
|
name="Default text"
|
||||||
preview={
|
preview={
|
||||||
<div className="bg-panel-accent flex size-full items-center justify-center rounded">
|
<div className="bg-accent flex size-full items-center justify-center rounded">
|
||||||
<span className="text-xs select-none">Default text</span>
|
<span className="text-xs select-none">Default text</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function ViewContent({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1 scrollbar-hidden">
|
||||||
<div className={cn("p-5", className)}>{children}</div>
|
<div className={cn("p-5", className)}>{children}</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
@@ -52,7 +52,7 @@ export function PanelBaseView({
|
|||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
className="flex h-full flex-col"
|
className="flex h-full flex-col"
|
||||||
>
|
>
|
||||||
<div className="bg-panel sticky top-0 z-10">
|
<div className="bg-background sticky top-0 z-10">
|
||||||
<div className="px-3 pt-3 pb-0">
|
<div className="px-3 pt-3 pb-0">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
|
|||||||
@@ -4,11 +4,23 @@ import { useCallback, useMemo, useRef } from "react";
|
|||||||
import useDeepCompareEffect from "use-deep-compare-effect";
|
import useDeepCompareEffect from "use-deep-compare-effect";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useRafLoop } from "@/hooks/use-raf-loop";
|
import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||||
|
import { useContainerSize } from "@/hooks/use-container-size";
|
||||||
|
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||||
import { buildScene } from "@/services/renderer/scene-builder";
|
import { buildScene } from "@/services/renderer/scene-builder";
|
||||||
import { getLastFrameTime } from "@/lib/time";
|
import { formatTimeCode, getLastFrameTime } from "@/lib/time";
|
||||||
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
||||||
|
import { EditableTimecode } from "@/components/editable-timecode";
|
||||||
|
import { invokeAction } from "@/lib/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
FullScreenIcon,
|
||||||
|
PauseIcon,
|
||||||
|
PlayIcon,
|
||||||
|
} from "@hugeicons/core-free-icons";
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
function usePreviewSize() {
|
function usePreviewSize() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
@@ -47,32 +59,134 @@ function RenderTreeController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PreviewPanel() {
|
export function PreviewPanel() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm">
|
<div
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2">
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border",
|
||||||
|
isFullscreen && "bg-background",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
|
||||||
<PreviewCanvas />
|
<PreviewCanvas />
|
||||||
<RenderTreeController />
|
<RenderTreeController />
|
||||||
</div>
|
</div>
|
||||||
|
<PreviewToolbar
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onToggleFullscreen={toggleFullscreen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewToolbar({
|
||||||
|
isFullscreen,
|
||||||
|
onToggleFullscreen,
|
||||||
|
}: {
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
}) {
|
||||||
|
const editor = useEditor();
|
||||||
|
const isPlaying = editor.playback.getIsPlaying();
|
||||||
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const totalDuration = editor.timeline.getTotalDuration();
|
||||||
|
const fps = editor.project.getActive().settings.fps;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
|
||||||
|
<div className="flex items-center mt-1">
|
||||||
|
<EditableTimecode
|
||||||
|
time={currentTime}
|
||||||
|
duration={totalDuration}
|
||||||
|
format="HH:MM:SS:FF"
|
||||||
|
fps={fps}
|
||||||
|
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{formatTimeCode({
|
||||||
|
timeInSeconds: totalDuration,
|
||||||
|
format: "HH:MM:SS:FF",
|
||||||
|
fps,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => invokeAction("toggle-play")}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="justify-self-end">
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={FullScreenIcon} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreviewCanvas() {
|
function PreviewCanvas() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const lastFrameRef = useRef(-1);
|
const lastFrameRef = useRef(-1);
|
||||||
const lastSceneRef = useRef<RootNode | null>(null);
|
const lastSceneRef = useRef<RootNode | null>(null);
|
||||||
const renderingRef = useRef(false);
|
const renderingRef = useRef(false);
|
||||||
const { width, height } = usePreviewSize();
|
const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
|
||||||
|
const containerSize = useContainerSize({ containerRef });
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
|
|
||||||
const renderer = useMemo(() => {
|
const renderer = useMemo(() => {
|
||||||
return new CanvasRenderer({
|
return new CanvasRenderer({
|
||||||
width,
|
width: nativeWidth,
|
||||||
height,
|
height: nativeHeight,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
}, [width, height, activeProject.settings.fps]);
|
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
|
||||||
|
|
||||||
|
const displaySize = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!nativeWidth ||
|
||||||
|
!nativeHeight ||
|
||||||
|
containerSize.width === 0 ||
|
||||||
|
containerSize.height === 0
|
||||||
|
) {
|
||||||
|
return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddingBuffer = 4;
|
||||||
|
const availableWidth = containerSize.width - paddingBuffer;
|
||||||
|
const availableHeight = containerSize.height - paddingBuffer;
|
||||||
|
|
||||||
|
const aspectRatio = nativeWidth / nativeHeight;
|
||||||
|
const containerAspect = availableWidth / availableHeight;
|
||||||
|
|
||||||
|
const displayWidth =
|
||||||
|
containerAspect > aspectRatio
|
||||||
|
? availableHeight * aspectRatio
|
||||||
|
: availableWidth;
|
||||||
|
const displayHeight =
|
||||||
|
containerAspect > aspectRatio
|
||||||
|
? availableHeight
|
||||||
|
: availableWidth / aspectRatio;
|
||||||
|
|
||||||
|
return { width: displayWidth, height: displayHeight };
|
||||||
|
}, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]);
|
||||||
|
|
||||||
const renderTree = editor.renderer.getRenderTree();
|
const renderTree = editor.renderer.getRenderTree();
|
||||||
|
|
||||||
@@ -109,13 +223,18 @@ function PreviewCanvas() {
|
|||||||
useRafLoop(render);
|
useRafLoop(render);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative flex h-full w-full items-center justify-center"
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={width}
|
width={nativeWidth}
|
||||||
height={height}
|
height={nativeHeight}
|
||||||
className="block max-h-full max-w-full border"
|
className="block border"
|
||||||
style={{
|
style={{
|
||||||
|
width: displaySize.width,
|
||||||
|
height: displaySize.height,
|
||||||
background:
|
background:
|
||||||
activeProject.settings.background.type === "blur"
|
activeProject.settings.background.type === "blur"
|
||||||
? "transparent"
|
? "transparent"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
import { Settings05Icon } from "@hugeicons/core-free-icons";
|
||||||
|
|
||||||
|
export function EmptyView() {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={Settings05Icon}
|
||||||
|
className="text-muted-foreground/75 size-10"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 text-center">
|
||||||
|
<p className="text-lg font-medium ">It's empty here</p>
|
||||||
|
<p className="text-muted-foreground text-sm text-balance">
|
||||||
|
Click an element on the timeline to edit its properties
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { AudioProperties } from "./audio-properties";
|
import { AudioProperties } from "./audio-properties";
|
||||||
import { VideoProperties } from "./video-properties";
|
import { VideoProperties } from "./video-properties";
|
||||||
import { TextProperties } from "./text-properties";
|
import { TextProperties } from "./text-properties";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { EmptyView } from "./empty-view";
|
||||||
import { Settings05Icon } from "@hugeicons/core-free-icons";
|
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||||
|
|
||||||
@@ -18,9 +17,9 @@ export function PropertiesPanel() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
|
||||||
{selectedElements.length > 0 ? (
|
{selectedElements.length > 0 ? (
|
||||||
<ScrollArea className="bg-panel h-full rounded-sm">
|
<ScrollArea className="h-full">
|
||||||
{elementsWithTracks.map(({ track, element }) => {
|
{elementsWithTracks.map(({ track, element }) => {
|
||||||
if (element.type === "text") {
|
if (element.type === "text") {
|
||||||
return (
|
return (
|
||||||
@@ -45,24 +44,6 @@ export function PropertiesPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<EmptyView />
|
<EmptyView />
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyView() {
|
|
||||||
return (
|
|
||||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={Settings05Icon}
|
|
||||||
className="text-muted-foreground/75 size-10"
|
|
||||||
strokeWidth={1}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-2 text-center">
|
|
||||||
<p className="text-lg font-medium">It's empty here</p>
|
|
||||||
<p className="text-muted-foreground text-sm text-balance">
|
|
||||||
Click an element on the timeline to edit its properties
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
|
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||||
|
|
||||||
interface PropertyItemProps {
|
interface PropertyItemProps {
|
||||||
direction?: "row" | "column";
|
direction?: "row" | "column";
|
||||||
@@ -57,35 +57,76 @@ interface PropertyGroupProps {
|
|||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
|
collapsible?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
titleClassName?: string;
|
hasBorderTop?: boolean;
|
||||||
|
hasBorderBottom?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PropertyGroup({
|
export function PropertyGroup({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
defaultExpanded = true,
|
defaultExpanded = true,
|
||||||
|
collapsible = true,
|
||||||
className,
|
className,
|
||||||
titleClassName,
|
hasBorderTop = true,
|
||||||
|
hasBorderBottom = true,
|
||||||
}: PropertyGroupProps) {
|
}: PropertyGroupProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PropertyItem direction="column" className={cn("gap-3", className)}>
|
<div
|
||||||
<button
|
className={cn(
|
||||||
type="button"
|
"flex flex-col",
|
||||||
className="flex items-center gap-1.5 cursor-pointer"
|
hasBorderTop && "border-t",
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
hasBorderBottom && "last:border-b",
|
||||||
>
|
className,
|
||||||
<PropertyItemLabel className={cn(titleClassName)}>
|
)}
|
||||||
{title}
|
>
|
||||||
</PropertyItemLabel>
|
{collapsible ? (
|
||||||
<HugeiconsIcon
|
<button
|
||||||
icon={ArrowDownIcon}
|
type="button"
|
||||||
className={cn("size-3", !isExpanded && "-rotate-90")}
|
className="flex items-center justify-between p-3.5 cursor-pointer"
|
||||||
/>
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
</button>
|
>
|
||||||
{isExpanded && <PropertyItemValue>{children}</PropertyItemValue>}
|
<PropertyGroupTitle isExpanded={isExpanded}>
|
||||||
</PropertyItem>
|
{title}
|
||||||
|
</PropertyGroupTitle>
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={isExpanded ? MinusSignIcon : PlusSignIcon}
|
||||||
|
className={cn(
|
||||||
|
"size-3",
|
||||||
|
isExpanded ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(collapsible ? isExpanded : true) && (
|
||||||
|
<div className="p-3 pt-0">{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyGroupTitle({
|
||||||
|
children,
|
||||||
|
isExpanded = false,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isExpanded?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium",
|
||||||
|
isExpanded ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+8
-8
@@ -15,12 +15,12 @@ import {
|
|||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "../../ui/context-menu";
|
} from "../../../ui/context-menu";
|
||||||
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
|
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
|
||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { TimelineTrackContent } from "./timeline-track";
|
import { TimelineTrackContent } from "./timeline-track";
|
||||||
import { TimelinePlayhead } from "./timeline-playhead";
|
import { TimelinePlayhead } from "./timeline-playhead";
|
||||||
import { SelectionBox } from "../selection-box";
|
import { SelectionBox } from "../../selection-box";
|
||||||
import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
|
import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
|
||||||
import { SnapIndicator } from "./snap-indicator";
|
import { SnapIndicator } from "./snap-indicator";
|
||||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||||
@@ -201,7 +201,7 @@ export function Timeline() {
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={
|
className={
|
||||||
"bg-panel relative flex h-full flex-col overflow-hidden rounded-sm"
|
"panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border"
|
||||||
}
|
}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
aria-label="Timeline"
|
aria-label="Timeline"
|
||||||
@@ -226,17 +226,17 @@ export function Timeline() {
|
|||||||
isVisible={showSnapIndicator}
|
isVisible={showSnapIndicator}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="bg-panel flex w-28 shrink-0 flex-col border-r">
|
<div className="bg-background flex w-28 shrink-0 flex-col border-r">
|
||||||
<div className="bg-panel flex h-4 items-center justify-between px-3">
|
<div className="bg-background flex h-4 items-center justify-between px-3">
|
||||||
<span className="opacity-0">.</span>
|
<span className="opacity-0">.</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-panel flex h-4 items-center justify-between px-3">
|
<div className="bg-background flex h-4 items-center justify-between px-3">
|
||||||
<span className="opacity-0">.</span>
|
<span className="opacity-0">.</span>
|
||||||
</div>
|
</div>
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<div
|
<div
|
||||||
ref={trackLabelsRef}
|
ref={trackLabelsRef}
|
||||||
className="bg-panel flex-1 overflow-y-auto"
|
className="bg-background flex-1 overflow-y-auto"
|
||||||
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
|
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
|
||||||
>
|
>
|
||||||
<ScrollArea className="size-full" ref={trackLabelsScrollRef}>
|
<ScrollArea className="size-full" ref={trackLabelsScrollRef}>
|
||||||
@@ -351,7 +351,7 @@ export function Timeline() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={timelineHeaderRef}
|
ref={timelineHeaderRef}
|
||||||
className="bg-panel sticky top-0 z-30 flex flex-col"
|
className="bg-background sticky top-0 z-30 flex flex-col"
|
||||||
>
|
>
|
||||||
<TimelineRuler
|
<TimelineRuler
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
+17
-5
@@ -19,7 +19,7 @@ import {
|
|||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "../../ui/context-menu";
|
} from "../../../ui/context-menu";
|
||||||
import type {
|
import type {
|
||||||
TimelineElement as TimelineElementType,
|
TimelineElement as TimelineElementType,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
@@ -166,7 +166,10 @@ export function TimelineElement({
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="z-200 w-64">
|
<ContextMenuContent className="z-200 w-64">
|
||||||
<ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}>
|
<ActionMenuItem
|
||||||
|
action="split"
|
||||||
|
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||||
|
>
|
||||||
Split
|
Split
|
||||||
</ActionMenuItem>
|
</ActionMenuItem>
|
||||||
<CopyMenuItem />
|
<CopyMenuItem />
|
||||||
@@ -185,7 +188,10 @@ export function TimelineElement({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedElements.length === 1 && (
|
{selectedElements.length === 1 && (
|
||||||
<ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
|
<ActionMenuItem
|
||||||
|
action="duplicate-selected"
|
||||||
|
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||||
|
>
|
||||||
Duplicate
|
Duplicate
|
||||||
</ActionMenuItem>
|
</ActionMenuItem>
|
||||||
)}
|
)}
|
||||||
@@ -446,7 +452,10 @@ function ElementContent({
|
|||||||
|
|
||||||
function CopyMenuItem() {
|
function CopyMenuItem() {
|
||||||
return (
|
return (
|
||||||
<ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
|
<ActionMenuItem
|
||||||
|
action="copy-selected"
|
||||||
|
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||||
|
>
|
||||||
Copy
|
Copy
|
||||||
</ActionMenuItem>
|
</ActionMenuItem>
|
||||||
);
|
);
|
||||||
@@ -502,7 +511,10 @@ function VisibilityMenuItem({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}>
|
<ActionMenuItem
|
||||||
|
action="toggle-elements-visibility-selected"
|
||||||
|
icon={getIcon()}
|
||||||
|
>
|
||||||
{isHidden ? "Show" : "Hide"}
|
{isHidden ? "Show" : "Hide"}
|
||||||
</ActionMenuItem>
|
</ActionMenuItem>
|
||||||
);
|
);
|
||||||
+21
-3
@@ -1,4 +1,4 @@
|
|||||||
import type { JSX } from "react";
|
import { type JSX, useLayoutEffect, useRef } from "react";
|
||||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
@@ -44,7 +44,21 @@ export function TimelineRuler({
|
|||||||
scrollRef: tracksScrollRef,
|
scrollRef: tracksScrollRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bufferPx = 200;
|
/**
|
||||||
|
* widens the virtualization buffer during zoom transitions.
|
||||||
|
* useScrollPosition lags one frame behind the scroll adjustment
|
||||||
|
* that useLayoutEffect applies after a zoom change.
|
||||||
|
*/
|
||||||
|
const prevZoomRef = useRef(zoomLevel);
|
||||||
|
const isZoomTransition = zoomLevel !== prevZoomRef.current;
|
||||||
|
const bufferPx = isZoomTransition
|
||||||
|
? Math.max(200, (scrollLeft + viewportWidth) * 0.15)
|
||||||
|
: 200;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
prevZoomRef.current = zoomLevel;
|
||||||
|
}, [zoomLevel]);
|
||||||
|
|
||||||
const visibleStartTime = Math.max(
|
const visibleStartTime = Math.max(
|
||||||
0,
|
0,
|
||||||
(scrollLeft - bufferPx) / pixelsPerSecond,
|
(scrollLeft - bufferPx) / pixelsPerSecond,
|
||||||
@@ -62,7 +76,11 @@ export function TimelineRuler({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const timelineTicks: Array<JSX.Element> = [];
|
const timelineTicks: Array<JSX.Element> = [];
|
||||||
for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; tickIndex += 1) {
|
for (
|
||||||
|
let tickIndex = startTickIndex;
|
||||||
|
tickIndex <= endTickIndex;
|
||||||
|
tickIndex += 1
|
||||||
|
) {
|
||||||
const time = tickIndex * tickIntervalSeconds;
|
const time = tickIndex * tickIntervalSeconds;
|
||||||
if (time > effectiveDuration) break;
|
if (time > effectiveDuration) break;
|
||||||
|
|
||||||
+15
-84
@@ -6,7 +6,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { SkipBack, SplitSquareHorizontal } from "lucide-react";
|
import { SplitSquareHorizontal } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
SplitButton,
|
SplitButton,
|
||||||
SplitButtonLeft,
|
SplitButtonLeft,
|
||||||
@@ -14,11 +14,9 @@ import {
|
|||||||
SplitButtonSeparator,
|
SplitButtonSeparator,
|
||||||
} from "@/components/ui/split-button";
|
} from "@/components/ui/split-button";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { formatTimeCode } from "@/lib/time";
|
|
||||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||||
import { EditableTimecode } from "@/components/editable-timecode";
|
import { ScenesView } from "../../scenes-view";
|
||||||
import { ScenesView } from "../scenes-view";
|
|
||||||
import { type TAction, invokeAction } from "@/lib/actions";
|
import { type TAction, invokeAction } from "@/lib/actions";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
@@ -32,8 +30,6 @@ import {
|
|||||||
Link04Icon,
|
Link04Icon,
|
||||||
SearchAddIcon,
|
SearchAddIcon,
|
||||||
SearchMinusIcon,
|
SearchMinusIcon,
|
||||||
PauseIcon,
|
|
||||||
PlayIcon,
|
|
||||||
Copy01Icon,
|
Copy01Icon,
|
||||||
AlignLeftIcon,
|
AlignLeftIcon,
|
||||||
AlignRightIcon,
|
AlignRightIcon,
|
||||||
@@ -82,7 +78,6 @@ export function TimelineToolbar({
|
|||||||
function ToolbarLeftSection() {
|
function ToolbarLeftSection() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const isPlaying = editor.playback.getIsPlaying();
|
|
||||||
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
|
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
|
||||||
|
|
||||||
const handleAction = ({
|
const handleAction = ({
|
||||||
@@ -99,32 +94,6 @@ function ToolbarLeftSection() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<TooltipProvider delayDuration={500}>
|
<TooltipProvider delayDuration={500}>
|
||||||
<ToolbarButton
|
|
||||||
icon={
|
|
||||||
isPlaying ? (
|
|
||||||
<HugeiconsIcon icon={PauseIcon} />
|
|
||||||
) : (
|
|
||||||
<HugeiconsIcon icon={PlayIcon} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
tooltip={isPlaying ? "Pause" : "Play"}
|
|
||||||
onClick={({ event }) =>
|
|
||||||
handleAction({ action: "toggle-play", event })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<SkipBack />}
|
|
||||||
tooltip="Go to start"
|
|
||||||
onClick={({ event }) => handleAction({ action: "goto-start", event })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="bg-border mx-1 h-6 w-px" />
|
|
||||||
|
|
||||||
<TimeDisplay />
|
|
||||||
|
|
||||||
<div className="bg-border mx-1 h-6 w-px" />
|
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||||
tooltip="Split element"
|
tooltip="Split element"
|
||||||
@@ -179,12 +148,8 @@ function ToolbarLeftSection() {
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={
|
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
|
||||||
<HugeiconsIcon
|
isActive={currentBookmarked}
|
||||||
icon={Bookmark02Icon}
|
|
||||||
className={currentBookmarked ? "fill-primary text-primary" : ""}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||||
onClick={({ event }) =>
|
onClick={({ event }) =>
|
||||||
handleAction({ action: "toggle-bookmark", event })
|
handleAction({ action: "toggle-bookmark", event })
|
||||||
@@ -196,34 +161,6 @@ function ToolbarLeftSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimeDisplay() {
|
|
||||||
const editor = useEditor();
|
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
|
||||||
const totalDuration = editor.timeline.getTotalDuration();
|
|
||||||
const fps = editor.project.getActive().settings.fps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row items-center justify-center px-2">
|
|
||||||
<EditableTimecode
|
|
||||||
time={currentTime}
|
|
||||||
duration={totalDuration}
|
|
||||||
format="HH:MM:SS:FF"
|
|
||||||
fps={fps}
|
|
||||||
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
|
||||||
className="text-center"
|
|
||||||
/>
|
|
||||||
<div className="text-muted-foreground px-2 font-mono text-xs">/</div>
|
|
||||||
<div className="text-muted-foreground text-center font-mono text-xs">
|
|
||||||
{formatTimeCode({
|
|
||||||
timeInSeconds: totalDuration,
|
|
||||||
format: "HH:MM:SS:FF",
|
|
||||||
fps,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SceneSelector() {
|
function SceneSelector() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const currentScene = editor.scenes.getActiveScene();
|
const currentScene = editor.scenes.getActiveScene();
|
||||||
@@ -265,26 +202,15 @@ function ToolbarRightSection({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<TooltipProvider delayDuration={500}>
|
<TooltipProvider delayDuration={500}>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={
|
icon={<HugeiconsIcon icon={MagnetIcon} />}
|
||||||
<HugeiconsIcon
|
isActive={snappingEnabled}
|
||||||
icon={MagnetIcon}
|
|
||||||
className={cn(snappingEnabled ? "text-primary" : "")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
tooltip="Auto snapping"
|
tooltip="Auto snapping"
|
||||||
onClick={() => toggleSnapping()}
|
onClick={() => toggleSnapping()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={
|
icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
|
||||||
<HugeiconsIcon
|
isActive={rippleEditingEnabled}
|
||||||
icon={Link04Icon}
|
|
||||||
className={cn(
|
|
||||||
rippleEditingEnabled ? "text-primary" : "",
|
|
||||||
"scale-110",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
tooltip="Ripple editing"
|
tooltip="Ripple editing"
|
||||||
onClick={() => toggleRippleEditing()}
|
onClick={() => toggleRippleEditing()}
|
||||||
/>
|
/>
|
||||||
@@ -329,21 +255,26 @@ function ToolbarButton({
|
|||||||
tooltip,
|
tooltip,
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
|
isActive,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={200}>
|
<Tooltip delayDuration={200}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant={isActive ? "secondary" : "text"}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => onClick({ event })}
|
onClick={(event) => onClick({ event })}
|
||||||
className={disabled ? "cursor-not-allowed opacity-50" : ""}
|
className={cn(
|
||||||
|
"rounded-sm",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</Button>
|
</Button>
|
||||||
+1
-6
@@ -9,7 +9,6 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
|||||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||||
import type { ElementDragState } from "@/types/timeline";
|
import type { ElementDragState } from "@/types/timeline";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { cn } from "@/utils/ui";
|
|
||||||
|
|
||||||
interface TimelineTrackContentProps {
|
interface TimelineTrackContentProps {
|
||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
@@ -63,13 +62,9 @@ export function TimelineTrackContent({
|
|||||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasSelectedElements = track.elements.some((element) =>
|
|
||||||
isElementSelected({ trackId: track.id, elementId: element.id }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
|
className="size-full"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (shouldIgnoreClick?.()) return;
|
if (shouldIgnoreClick?.()) return;
|
||||||
clearElementSelection();
|
clearElementSelection();
|
||||||
@@ -22,8 +22,8 @@ export function ThemeToggle({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="text"
|
variant="ghost"
|
||||||
className={cn("h-7", className)}
|
className={cn("size-8", className)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
setTheme(theme === "dark" ? "light" : "dark");
|
setTheme(theme === "dark" ? "light" : "dark");
|
||||||
onToggle?.(e);
|
onToggle?.(e);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground hover:bg-primary-hover",
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
background:
|
background:
|
||||||
"bg-background text-foreground hover:bg-background/90",
|
"bg-background text-foreground hover:bg-background/90",
|
||||||
foreground:
|
foreground:
|
||||||
@@ -22,13 +22,14 @@ const buttonVariants = cva(
|
|||||||
outline:
|
outline:
|
||||||
"border border-border bg-transparent hover:bg-accent/50",
|
"border border-border bg-transparent hover:bg-accent/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-foreground/15 border border-input",
|
"bg-secondary text-secondary-foreground border border-secondary-border",
|
||||||
text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
|
text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
|
||||||
|
ghost: "bg-transparent hover:bg-accent",
|
||||||
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
|
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9.5 px-4 py-2",
|
default: "h-9.5 px-4 py-2",
|
||||||
sm: "h-8 px-3 text-xs",
|
sm: "h-8 p-1 px-2 text-xs rounded-sm",
|
||||||
lg: "h-10 p-5 px-6",
|
lg: "h-10 p-5 px-6",
|
||||||
icon: "size-7",
|
icon: "size-7",
|
||||||
text: "p-0",
|
text: "p-0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Input } from "./input";
|
|||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
onChangeEnd?: (value: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ const hsvToHex = (h: number, s: number, v: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||||
({ className, value = "FFFFFF", onChange, containerRef, ...props }, ref) => {
|
({ className, value = "FFFFFF", onChange, onChangeEnd, containerRef, ...props }, ref) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>(
|
const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>(
|
||||||
null,
|
null,
|
||||||
@@ -102,6 +103,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||||||
const saturationRef = useRef<HTMLButtonElement>(null);
|
const saturationRef = useRef<HTMLButtonElement>(null);
|
||||||
const hueRef = useRef<HTMLButtonElement>(null);
|
const hueRef = useRef<HTMLButtonElement>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const latestDragColorRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const [h, s, v] = hexToHsv(value);
|
const [h, s, v] = hexToHsv(value);
|
||||||
const displayHue = s > 0 ? h : internalHue;
|
const displayHue = s > 0 ? h : internalHue;
|
||||||
@@ -144,6 +146,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||||||
const newS = x;
|
const newS = x;
|
||||||
const newV = 1 - y;
|
const newV = 1 - y;
|
||||||
const newHex = hsvToHex(displayHue, newS, newV);
|
const newHex = hsvToHex(displayHue, newS, newV);
|
||||||
|
latestDragColorRef.current = newHex;
|
||||||
onChange?.(newHex);
|
onChange?.(newHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,12 +160,17 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||||||
setInternalHue(newH);
|
setInternalHue(newH);
|
||||||
if (s > 0) {
|
if (s > 0) {
|
||||||
const newHex = hsvToHex(newH, s, v);
|
const newHex = hsvToHex(newH, s, v);
|
||||||
|
latestDragColorRef.current = newHex;
|
||||||
onChange?.(newHex);
|
onChange?.(newHex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
|
if (latestDragColorRef.current !== null && onChangeEnd) {
|
||||||
|
onChangeEnd(latestDragColorRef.current);
|
||||||
|
latestDragColorRef.current = null;
|
||||||
|
}
|
||||||
setIsDragging(null);
|
setIsDragging(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,6 +195,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||||||
const newS = x;
|
const newS = x;
|
||||||
const newV = 1 - y;
|
const newV = 1 - y;
|
||||||
const newHex = hsvToHex(displayHue, newS, newV);
|
const newHex = hsvToHex(displayHue, newS, newV);
|
||||||
|
latestDragColorRef.current = newHex;
|
||||||
onChange?.(newHex);
|
onChange?.(newHex);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,6 +210,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||||||
setInternalHue(newH);
|
setInternalHue(newH);
|
||||||
if (s > 0) {
|
if (s > 0) {
|
||||||
const newHex = hsvToHex(newH, s, v);
|
const newHex = hsvToHex(newH, s, v);
|
||||||
|
latestDragColorRef.current = newHex;
|
||||||
onChange?.(newHex);
|
onChange?.(newHex);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -235,14 +245,14 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-panel-accent flex h-9 items-center gap-2 rounded-full px-[0.45rem]",
|
"bg-accent flex h-8 items-center gap-2 rounded-md px-[0.45rem]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
className="size-6 cursor-pointer rounded-full hover:ring-2 hover:ring-white/20"
|
className="size-4.5 cursor-pointer border rounded-sm hover:ring-2 hover:ring-white/20"
|
||||||
style={{ backgroundColor: `#${value}` }}
|
style={{ backgroundColor: `#${value}` }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -259,7 +269,8 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-1 items-center">
|
<div className="flex flex-1 items-center">
|
||||||
<Input
|
<Input
|
||||||
className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0"
|
className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0 uppercase"
|
||||||
|
size="sm"
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"data-[selected=true]:bg-popover-hover data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const dropdownMenuItemVariants = cva(
|
const dropdownMenuItemVariants = cva(
|
||||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants";
|
import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants";
|
||||||
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
interface FontPickerProps {
|
interface FontPickerProps {
|
||||||
defaultValue?: FontFamily;
|
defaultValue?: FontFamily;
|
||||||
@@ -21,16 +22,15 @@ export function FontPicker({
|
|||||||
return (
|
return (
|
||||||
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
|
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className={`bg-panel-accent h-9 w-full text-base ${className || ""}`}
|
className={cn("w-full", className)}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Select a font" className="text-sm" />
|
<SelectValue placeholder="Select a font" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{FONT_OPTIONS.map((font) => (
|
{FONT_OPTIONS.map((font) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={font.value}
|
key={font.value}
|
||||||
value={font.value}
|
value={font.value}
|
||||||
className="text-sn"
|
|
||||||
style={{ fontFamily: font.value }}
|
style={{ fontFamily: font.value }}
|
||||||
>
|
>
|
||||||
{font.label}
|
{font.label}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function InputWithBack({
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-panel-accent !size-9 rounded-full"
|
className="bg-accent !size-9 rounded-full"
|
||||||
>
|
>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -77,7 +77,7 @@ export function InputWithBack({
|
|||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="bg-panel-accent w-full pl-9"
|
className="bg-accent w-full pl-9"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
const labelVariants = cva(
|
const labelVariants = cva(
|
||||||
"text-sm text-muted-foreground font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
"text-xs text-muted-foreground font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
);
|
);
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
const Popover = PopoverPrimitive.Root;
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const SelectGroup = SelectPrimitive.Group;
|
|||||||
const SelectValue = SelectPrimitive.Value;
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
const selectItemVariants = cva(
|
const selectItemVariants = cva(
|
||||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
"relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -35,7 +35,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
"focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none",
|
"focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -159,7 +159,7 @@ const SelectSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("bg-border/60 mx-1 my-2 h-px", className)}
|
className={cn("bg-border mx-1 my-2 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const Slider = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track className="bg-primary/20 relative h-1.5 w-full grow overflow-hidden rounded-full">
|
<SliderPrimitive.Track className="bg-accent relative h-1.5 w-full grow overflow-hidden rounded-full">
|
||||||
<SliderPrimitive.Range className="bg-primary absolute h-full" />
|
<SliderPrimitive.Range className="bg-primary absolute h-full" />
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
<SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" />
|
<SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input bg-panel-accent inline-flex h-7 overflow-hidden rounded-lg border",
|
"border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,7 +39,7 @@ const SplitButtonSide = forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
variant="text"
|
variant="text"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-panel-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
|
"bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
|
||||||
onClick
|
onClick
|
||||||
? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
|
? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
|
||||||
: "cursor-default select-text",
|
: "cursor-default select-text",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-panel-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
|
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -60,10 +60,7 @@ export function useEdgeAutoScroll({
|
|||||||
mouseXRelative > viewportWidth - edgeThreshold &&
|
mouseXRelative > viewportWidth - edgeThreshold &&
|
||||||
rulerViewport.scrollLeft < scrollMax
|
rulerViewport.scrollLeft < scrollMax
|
||||||
) {
|
) {
|
||||||
const edgeDistance = Math.max(
|
const edgeDistance = Math.max(0, viewportWidth - mouseXRelative);
|
||||||
0,
|
|
||||||
viewportWidth - edgeThreshold - mouseXRelative,
|
|
||||||
);
|
|
||||||
const intensity = 1 - edgeDistance / edgeThreshold;
|
const intensity = 1 - edgeDistance / edgeThreshold;
|
||||||
scrollSpeed = maxScrollSpeed * intensity;
|
scrollSpeed = maxScrollSpeed * intensity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function useTimelineZoom({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [zoomLevel, setZoomLevel] = useState(() => {
|
const [zoomLevel, setZoomLevelRaw] = useState(() => {
|
||||||
if (initialZoom !== undefined) {
|
if (initialZoom !== undefined) {
|
||||||
hasInitializedRef.current = true;
|
hasInitializedRef.current = true;
|
||||||
return Math.max(
|
return Math.max(
|
||||||
@@ -56,6 +56,18 @@ export function useTimelineZoom({
|
|||||||
});
|
});
|
||||||
const previousZoomRef = useRef(zoomLevel);
|
const previousZoomRef = useRef(zoomLevel);
|
||||||
const hasRestoredScrollRef = useRef(false);
|
const hasRestoredScrollRef = useRef(false);
|
||||||
|
const preZoomScrollLeftRef = useRef(0);
|
||||||
|
|
||||||
|
const setZoomLevel = useCallback(
|
||||||
|
(updater: number | ((prev: number) => number)) => {
|
||||||
|
const scrollElement = tracksScrollRef.current;
|
||||||
|
if (scrollElement) {
|
||||||
|
preZoomScrollLeftRef.current = scrollElement.scrollLeft;
|
||||||
|
}
|
||||||
|
setZoomLevelRaw(updater);
|
||||||
|
},
|
||||||
|
[tracksScrollRef],
|
||||||
|
);
|
||||||
|
|
||||||
const handleWheel = useCallback(
|
const handleWheel = useCallback(
|
||||||
(event: ReactWheelEvent) => {
|
(event: ReactWheelEvent) => {
|
||||||
@@ -82,7 +94,7 @@ export function useTimelineZoom({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[minZoom],
|
[minZoom, setZoomLevel],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,7 +111,7 @@ export function useTimelineZoom({
|
|||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}, [minZoom, initialZoom]);
|
}, [minZoom, initialZoom, setZoomLevel]);
|
||||||
|
|
||||||
const wrappedSetZoomLevel = useCallback(
|
const wrappedSetZoomLevel = useCallback(
|
||||||
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
|
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
|
||||||
@@ -115,7 +127,7 @@ export function useTimelineZoom({
|
|||||||
return clampedZoom;
|
return clampedZoom;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[minZoom],
|
[minZoom, setZoomLevel],
|
||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -128,7 +140,7 @@ export function useTimelineZoom({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentScrollLeft = scrollElement.scrollLeft;
|
const currentScrollLeft = preZoomScrollLeftRef.current;
|
||||||
const playheadTime = editor.playback.getCurrentTime();
|
const playheadTime = editor.playback.getCurrentTime();
|
||||||
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
|
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useContainerSize({
|
||||||
|
containerRef,
|
||||||
|
}: {
|
||||||
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
|
}) {
|
||||||
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width, height } = entry.contentRect;
|
||||||
|
setSize({ width, height });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(container);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useFullscreen({
|
||||||
|
containerRef,
|
||||||
|
}: {
|
||||||
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
|
}) {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChange = () => {
|
||||||
|
setIsFullscreen(document.fullscreenElement !== null);
|
||||||
|
};
|
||||||
|
document.addEventListener("fullscreenchange", handleChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("fullscreenchange", handleChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
containerRef.current.requestFullscreen();
|
||||||
|
}
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
|
return { isFullscreen, toggleFullscreen };
|
||||||
|
}
|
||||||
@@ -137,6 +137,16 @@ export function usePreviewInteraction({
|
|||||||
const deltaX = currentPos.x - dragStateRef.current.startX;
|
const deltaX = currentPos.x - dragStateRef.current.startX;
|
||||||
const deltaY = currentPos.y - dragStateRef.current.startY;
|
const deltaY = currentPos.y - dragStateRef.current.startY;
|
||||||
|
|
||||||
|
const hasMovement = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
|
||||||
|
|
||||||
|
|
||||||
|
if (!hasMovement) {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// revert to pre-drag state so the command captures the correct undo snapshot
|
// revert to pre-drag state so the command captures the correct undo snapshot
|
||||||
editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot);
|
editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot);
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { persist } from "zustand/middleware";
|
|
||||||
|
|
||||||
export type TextPropertiesTab = "text" | "transform";
|
|
||||||
|
|
||||||
export interface TextPropertiesTabMeta {
|
|
||||||
value: TextPropertiesTab;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [
|
|
||||||
{ value: "text", label: "Text" },
|
|
||||||
{ value: "transform", label: "Transform" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
|
|
||||||
return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextPropertiesState {
|
|
||||||
activeTab: TextPropertiesTab;
|
|
||||||
setActiveTab: (tab: TextPropertiesTab) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTextPropertiesStore = create<TextPropertiesState>()(
|
|
||||||
persist(
|
|
||||||
(set) => ({
|
|
||||||
activeTab: "text",
|
|
||||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
|
||||||
}),
|
|
||||||
{ name: "text-properties" },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Reference in New Issue
Block a user