mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
we're basically done
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-keybindings.ts:16","message":"keybindings_listener_mount","data":{"keybindingsEnabled":false,"isRecording":false,"keybindingsCount":24},"timestamp":1769522187702}
|
||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-keybindings.ts:16","message":"keybindings_listener_mount","data":{"keybindingsEnabled":false,"isRecording":false,"keybindingsCount":24},"timestamp":1769522187709}
|
||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-keybindings.ts:16","message":"keybindings_listener_mount","data":{"keybindingsEnabled":true,"isRecording":false,"keybindingsCount":24},"timestamp":1769522187729}
|
||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-keybindings.ts:23","message":"keydown_received","data":{"key":" ","code":"Space","ctrlKey":false,"metaKey":false,"altKey":false,"shiftKey":false,"targetTag":"BODY"},"timestamp":1769522205815}
|
||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-keybindings.ts:107","message":"invoking_action","data":{"boundAction":"toggle-play","binding":"space"},"timestamp":1769522205816}
|
||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"B","location":"use-keybindings.ts:70","message":"binding_computed","data":{"binding":"space"},"timestamp":1769522205816}
|
||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"registry.ts:63","message":"invoke_action_dispatch","data":{"action":"toggle-play","handlersCount":1,"hasHandlers":true,"trigger":"keypress"},"timestamp":1769522205817}
|
||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"E","location":"use-keybindings.ts:23","message":"keydown_received","data":{"key":" ","code":"Space","ctrlKey":false,"metaKey":false,"altKey":false,"shiftKey":false,"targetTag":"BODY"},"timestamp":1769522206526}
|
||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"use-keybindings.ts:107","message":"invoking_action","data":{"boundAction":"toggle-play","binding":"space"},"timestamp":1769522206526}
|
||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"D","location":"registry.ts:63","message":"invoke_action_dispatch","data":{"action":"toggle-play","handlersCount":1,"hasHandlers":true,"trigger":"keypress"},"timestamp":1769522206526}
|
||||||
|
{"sessionId":"debug-session","runId":"pre-fix","hypothesisId":"B","location":"use-keybindings.ts:70","message":"binding_computed","data":{"binding":"space"},"timestamp":1769522206526}
|
||||||
@@ -113,6 +113,7 @@ index.ts
|
|||||||
renderer: RendererManager
|
renderer: RendererManager
|
||||||
save: SaveManager
|
save: SaveManager
|
||||||
audio: AudioManager
|
audio: AudioManager
|
||||||
|
selection: SelectionManager
|
||||||
static getInstance(): EditorCore
|
static getInstance(): EditorCore
|
||||||
static reset(): void
|
static reset(): void
|
||||||
}
|
}
|
||||||
@@ -925,6 +926,13 @@ element-utils.ts
|
|||||||
startTime: number;
|
startTime: number;
|
||||||
buffer?: AudioBuffer;
|
buffer?: AudioBuffer;
|
||||||
}): CreateLibraryAudioElement
|
}): CreateLibraryAudioElement
|
||||||
|
export function getElementsAtTime({
|
||||||
|
tracks,
|
||||||
|
time,
|
||||||
|
}: {
|
||||||
|
tracks: TimelineTrack[];
|
||||||
|
time: number;
|
||||||
|
}): { trackId: string; elementId: string }[]
|
||||||
|
|
||||||
index.ts
|
index.ts
|
||||||
export function calculateTotalDuration({
|
export function calculateTotalDuration({
|
||||||
@@ -1692,7 +1700,7 @@ platform.ts
|
|||||||
export function getPlatformAlternateKey(): string
|
export function getPlatformAlternateKey(): string
|
||||||
export function isAppleDevice(): boolean
|
export function isAppleDevice(): boolean
|
||||||
|
|
||||||
strings.ts
|
string.ts
|
||||||
export function capitalizeFirstLetter({ string }: { string: string })
|
export function capitalizeFirstLetter({ string }: { string: string })
|
||||||
export function uppercase({ string }: { string: string })
|
export function uppercase({ string }: { string: string })
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default async function BlogPage() {
|
|||||||
function BlogPostItem({ post }: { post: Post }) {
|
function BlogPostItem({ post }: { post: Post }) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/blog/${post.slug}`}>
|
<Link href={`/blog/${post.slug}`}>
|
||||||
<div className="flex h-auto w-full items-center justify-between py-6 opacity-100 transition-opacity hover:opacity-75">
|
<div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-xl font-semibold">{post.title}</h2>
|
<h2 className="text-xl font-semibold">{post.title}</h2>
|
||||||
<p className="text-muted-foreground">{post.description}</p>
|
<p className="text-muted-foreground">{post.description}</p>
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function AllContributorsSection({
|
|||||||
href={contributor.html_url}
|
href={contributor.html_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="opacity-100 transition-opacity hover:opacity-70"
|
className="opacity-100 hover:opacity-70"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-2 p-2">
|
<div className="flex flex-col items-center gap-2 p-2">
|
||||||
<Avatar className="size-16">
|
<Avatar className="size-16">
|
||||||
@@ -214,7 +214,7 @@ function ExternalToolsSection() {
|
|||||||
<p className="text-muted-foreground">Tools we use to build OpenCut</p>
|
<p className="text-muted-foreground">Tools we use to build OpenCut</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
|
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
{EXTERNAL_TOOLS.map((tool, index) => (
|
{EXTERNAL_TOOLS.map((tool, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={tool.url}
|
key={tool.url}
|
||||||
@@ -224,10 +224,8 @@ function ExternalToolsSection() {
|
|||||||
style={{ animationDelay: `${index * 100}ms` }}
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
>
|
>
|
||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardContent className="flex h-full flex-col gap-4 p-6 text-center">
|
<CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
|
||||||
<div className="bg-muted/50 mx-auto flex size-12 items-center justify-center rounded-full">
|
<tool.icon className="size-8" />
|
||||||
<tool.icon className="size-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
<h3 className="text-lg font-semibold">{tool.name}</h3>
|
<h3 className="text-lg font-semibold">{tool.name}</h3>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
--popover: hsl(0, 0%, 100%);
|
--popover: hsl(0, 0%, 100%);
|
||||||
--popover-foreground: hsl(0 0% 2%);
|
--popover-foreground: hsl(0 0% 2%);
|
||||||
--primary: hsl(203, 100%, 50%);
|
--primary: hsl(203, 100%, 50%);
|
||||||
|
--primary-hover: hsl(203, 100%, 45%);
|
||||||
--primary-foreground: hsl(0, 0%, 100%);
|
--primary-foreground: hsl(0, 0%, 100%);
|
||||||
--secondary: hsl(216, 13%, 92%);
|
--secondary: hsl(216, 13%, 92%);
|
||||||
--secondary-foreground: hsl(0 0% 2%);
|
--secondary-foreground: hsl(0 0% 2%);
|
||||||
@@ -44,9 +45,6 @@
|
|||||||
--sidebar-ring: hsl(0 0% 16.9%);
|
--sidebar-ring: hsl(0 0% 16.9%);
|
||||||
--panel-background: hsl(216 13% 96%);
|
--panel-background: hsl(216 13% 96%);
|
||||||
--panel-accent: hsl(216, 8%, 90%);
|
--panel-accent: hsl(216, 8%, 90%);
|
||||||
|
|
||||||
--radius: 1rem;
|
|
||||||
|
|
||||||
--sidebar: hsl(0 0% 98%);
|
--sidebar: hsl(0 0% 98%);
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
@@ -56,7 +54,8 @@
|
|||||||
--card-foreground: hsl(0 0% 89%);
|
--card-foreground: hsl(0 0% 89%);
|
||||||
--popover: hsl(0 0% 14.9%);
|
--popover: hsl(0 0% 14.9%);
|
||||||
--popover-foreground: hsl(0 0% 98%);
|
--popover-foreground: hsl(0 0% 98%);
|
||||||
--primary: hsl(205, 84%, 53%);
|
--primary: hsl(203, 100%, 50%);
|
||||||
|
--primary-hover: hsl(203, 100%, 45%);
|
||||||
--primary-foreground: hsl(0 0% 9%);
|
--primary-foreground: hsl(0 0% 9%);
|
||||||
--secondary: hsl(0 0% 14.9%);
|
--secondary: hsl(0 0% 14.9%);
|
||||||
--secondary-foreground: hsl(0 0% 98%);
|
--secondary-foreground: hsl(0 0% 98%);
|
||||||
@@ -124,15 +123,16 @@
|
|||||||
--font-sans: var(--font-inter), sans-serif;
|
--font-sans: var(--font-inter), sans-serif;
|
||||||
|
|
||||||
/* Font sizes */
|
/* Font sizes */
|
||||||
--text-base: 0.95rem;
|
--text-xl: 1.20rem;
|
||||||
|
--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-xs--line-height: calc(1 / 0.8);
|
||||||
|
|
||||||
/* Border radius */
|
/* Border radius */
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: 0.82rem;
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: 0.65rem;
|
||||||
--radius-sm: calc(var(--radius) - 8px);
|
--radius-sm: 0.35rem;
|
||||||
|
|
||||||
/* Palette mapped to root design tokens */
|
/* Palette mapped to root design tokens */
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
|
|
||||||
--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-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,11 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{projectsToDisplay.map((project) => (
|
{projectsToDisplay.map((project) => (
|
||||||
<ProjectItem key={project.id} project={project} />
|
<ProjectItem
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
allProjectIds={projectsToDisplay.map((p) => p.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -144,10 +148,10 @@ function ProjectsHeader() {
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
|
||||||
<div className="hidden md:flex rounded-full border p-1 h-10">
|
<div className="hidden md:flex rounded-md border p-1 h-10">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`p-2 rounded-full cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`}
|
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`}
|
||||||
onClick={() => setViewMode({ viewMode: "grid" })}
|
onClick={() => setViewMode({ viewMode: "grid" })}
|
||||||
onKeyDown={(event) =>
|
onKeyDown={(event) =>
|
||||||
event.key === "Enter" && setViewMode({ viewMode: "grid" })
|
event.key === "Enter" && setViewMode({ viewMode: "grid" })
|
||||||
@@ -159,7 +163,7 @@ function ProjectsHeader() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`p-2 rounded-full cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`}
|
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`}
|
||||||
onClick={() => setViewMode({ viewMode: "list" })}
|
onClick={() => setViewMode({ viewMode: "list" })}
|
||||||
onKeyDown={(event) =>
|
onKeyDown={(event) =>
|
||||||
event.key === "Enter" && setViewMode({ viewMode: "list" })
|
event.key === "Enter" && setViewMode({ viewMode: "list" })
|
||||||
@@ -219,7 +223,7 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-16 z-10 flex items-center justify-between px-6 h-14 bg-background">
|
<div className="sticky top-16 z-10 flex items-center justify-between px-6 h-14 pt-2 bg-background">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label
|
<Label
|
||||||
className="flex items-center gap-3 cursor-pointer px-2"
|
className="flex items-center gap-3 cursor-pointer px-2"
|
||||||
@@ -298,7 +302,7 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedProjectCount > 1 ? <ProjectActions /> : null}
|
{selectedProjectCount > 0 ? <ProjectActions /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -518,20 +522,24 @@ function NewProjectButton() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
size="lg"
|
||||||
className="flex px-5 md:px-6"
|
className="flex px-5 md:px-6"
|
||||||
onClick={handleCreateProject}
|
onClick={handleCreateProject}
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={PlusSignIcon} />
|
|
||||||
<span className="text-sm font-medium hidden md:block">New project</span>
|
<span className="text-sm font-medium hidden md:block">New project</span>
|
||||||
<span className="text-sm font-medium block md:hidden">New</span>
|
<span className="text-sm font-medium block md:hidden">New</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectItem({ project }: { project: TProjectMetadata }) {
|
function ProjectItem({
|
||||||
const { selectedProjectIds, viewMode, setProjectSelected } =
|
project,
|
||||||
|
allProjectIds,
|
||||||
|
}: {
|
||||||
|
project: TProjectMetadata;
|
||||||
|
allProjectIds: string[];
|
||||||
|
}) {
|
||||||
|
const { selectedProjectIds, viewMode, setProjectSelected, selectProjectRange } =
|
||||||
useProjectsStore();
|
useProjectsStore();
|
||||||
const selectedProjectIdSet = new Set(selectedProjectIds);
|
const selectedProjectIdSet = new Set(selectedProjectIds);
|
||||||
const isSelected = selectedProjectIdSet.has(project.id);
|
const isSelected = selectedProjectIdSet.has(project.id);
|
||||||
@@ -541,6 +549,20 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
|
|||||||
const isMultiSelect = selectedProjectCount > 1;
|
const isMultiSelect = selectedProjectCount > 1;
|
||||||
const isGridView = viewMode === "grid";
|
const isGridView = viewMode === "grid";
|
||||||
|
|
||||||
|
const handleCheckboxChange = ({
|
||||||
|
checked,
|
||||||
|
shiftKey,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
}) => {
|
||||||
|
if (shiftKey && checked) {
|
||||||
|
selectProjectRange({ projectId: project.id, allProjectIds });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProjectSelected({ projectId: project.id, isSelected: checked });
|
||||||
|
};
|
||||||
|
|
||||||
const gridContent = (
|
const gridContent = (
|
||||||
<Card className="bg-background overflow-hidden border-none p-0">
|
<Card className="bg-background overflow-hidden border-none p-0">
|
||||||
<div className="bg-muted relative aspect-video">
|
<div className="bg-muted relative aspect-video">
|
||||||
@@ -567,7 +589,7 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="flex flex-col gap-2 px-0 pt-4">
|
<CardContent className="flex flex-col gap-2 px-0 pt-4">
|
||||||
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium transition-colors">
|
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium">
|
||||||
{project.name}
|
{project.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
|
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
|
||||||
@@ -595,7 +617,7 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0 transition-colors">
|
<h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0">
|
||||||
{project.name}
|
{project.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -617,12 +639,14 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
|
|||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={(checked) =>
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
setProjectSelected({
|
onClick={(event) => {
|
||||||
projectId: project.id,
|
handleCheckboxChange({
|
||||||
isSelected: checked === true,
|
checked: !isSelected,
|
||||||
})
|
shiftKey: event.shiftKey,
|
||||||
}
|
});
|
||||||
|
}}
|
||||||
|
onCheckedChange={() => {}}
|
||||||
className="size-5 shrink-0"
|
className="size-5 shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -657,12 +681,14 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
|
|||||||
<>
|
<>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={(checked) =>
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
setProjectSelected({
|
onClick={(event) => {
|
||||||
projectId: project.id,
|
handleCheckboxChange({
|
||||||
isSelected: checked === true,
|
checked: !isSelected,
|
||||||
})
|
shiftKey: event.shiftKey,
|
||||||
}
|
});
|
||||||
|
}}
|
||||||
|
onCheckedChange={() => {}}
|
||||||
className={`absolute z-10 size-5 top-3 left-3 ${
|
className={`absolute z-10 size-5 top-3 left-3 ${
|
||||||
isSelected || isDropdownOpen
|
isSelected || isDropdownOpen
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ProjectsState {
|
|||||||
sortOrder: "asc" | "desc";
|
sortOrder: "asc" | "desc";
|
||||||
viewMode: ProjectsViewMode;
|
viewMode: ProjectsViewMode;
|
||||||
selectedProjectIds: string[];
|
selectedProjectIds: string[];
|
||||||
|
lastSelectedProjectId: string | null;
|
||||||
isHydrated: boolean;
|
isHydrated: boolean;
|
||||||
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
|
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
|
||||||
setSearchQuery: ({ query }: { query: string }) => void;
|
setSearchQuery: ({ query }: { query: string }) => void;
|
||||||
@@ -26,6 +27,13 @@ interface ProjectsState {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
selectProjectRange: ({
|
||||||
|
projectId,
|
||||||
|
allProjectIds,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
allProjectIds: string[];
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getNextSelectedProjectIds = ({
|
const getNextSelectedProjectIds = ({
|
||||||
@@ -56,6 +64,7 @@ export const useProjectsStore = create<ProjectsState>()(
|
|||||||
sortOrder: "desc",
|
sortOrder: "desc",
|
||||||
viewMode: "grid",
|
viewMode: "grid",
|
||||||
selectedProjectIds: [],
|
selectedProjectIds: [],
|
||||||
|
lastSelectedProjectId: null,
|
||||||
isHydrated: false,
|
isHydrated: false,
|
||||||
setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
|
setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
|
||||||
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
||||||
@@ -68,7 +77,8 @@ export const useProjectsStore = create<ProjectsState>()(
|
|||||||
setViewMode: ({ viewMode }) => set({ viewMode }),
|
setViewMode: ({ viewMode }) => set({ viewMode }),
|
||||||
setSelectedProjects: ({ projectIds }) =>
|
setSelectedProjects: ({ projectIds }) =>
|
||||||
set({ selectedProjectIds: projectIds }),
|
set({ selectedProjectIds: projectIds }),
|
||||||
clearSelectedProjects: () => set({ selectedProjectIds: [] }),
|
clearSelectedProjects: () =>
|
||||||
|
set({ selectedProjectIds: [], lastSelectedProjectId: null }),
|
||||||
setProjectSelected: ({ projectId, isSelected }) =>
|
setProjectSelected: ({ projectId, isSelected }) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
selectedProjectIds: getNextSelectedProjectIds({
|
selectedProjectIds: getNextSelectedProjectIds({
|
||||||
@@ -76,7 +86,37 @@ export const useProjectsStore = create<ProjectsState>()(
|
|||||||
projectId,
|
projectId,
|
||||||
isSelected,
|
isSelected,
|
||||||
}),
|
}),
|
||||||
|
lastSelectedProjectId: isSelected ? projectId : state.lastSelectedProjectId,
|
||||||
})),
|
})),
|
||||||
|
selectProjectRange: ({ projectId, allProjectIds }) =>
|
||||||
|
set((state) => {
|
||||||
|
const anchorId = state.lastSelectedProjectId;
|
||||||
|
if (!anchorId) {
|
||||||
|
return {
|
||||||
|
selectedProjectIds: [projectId],
|
||||||
|
lastSelectedProjectId: projectId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorIndex = allProjectIds.indexOf(anchorId);
|
||||||
|
const targetIndex = allProjectIds.indexOf(projectId);
|
||||||
|
|
||||||
|
if (anchorIndex === -1 || targetIndex === -1) {
|
||||||
|
return {
|
||||||
|
selectedProjectIds: [projectId],
|
||||||
|
lastSelectedProjectId: projectId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = Math.min(anchorIndex, targetIndex);
|
||||||
|
const endIndex = Math.max(anchorIndex, targetIndex);
|
||||||
|
const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
|
||||||
|
|
||||||
|
const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
|
||||||
|
return {
|
||||||
|
selectedProjectIds: Array.from(merged),
|
||||||
|
};
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "projects-view-mode",
|
name: "projects-view-mode",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function MediaDragOverlay({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center transition-all duration-200"
|
className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isProcessing || !onClick}
|
disabled={isProcessing || !onClick}
|
||||||
onClick={(event) => handleClick({ event })}
|
onClick={(event) => handleClick({ event })}
|
||||||
@@ -50,7 +50,7 @@ export function MediaDragOverlay({
|
|||||||
<div className="w-full max-w-xs">
|
<div className="w-full max-w-xs">
|
||||||
<div className="bg-muted/50 h-2 w-full rounded-full">
|
<div className="bg-muted/50 h-2 w-full rounded-full">
|
||||||
<div
|
<div
|
||||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
className="bg-primary h-2 rounded-full"
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ function FadeOverlay({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute right-0 left-0 h-6 transition-opacity duration-200",
|
"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-panel top-0 bg-gradient-to-b to-transparent"
|
||||||
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
|
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
|
||||||
|
|||||||
@@ -196,24 +196,11 @@ export function MediaView() {
|
|||||||
<input {...fileInputProps} />
|
<input {...fileInputProps} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`relative flex h-full flex-col gap-1 transition-colors ${isDragOver ? "bg-accent/30" : ""}`}
|
className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
>
|
>
|
||||||
<div className="bg-panel p-3 pb-2">
|
<div className="bg-panel py-2 px-4 flex items-center justify-between border-b">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-muted-foreground text-sm">Assets</span>
|
||||||
<Button
|
|
||||||
variant="foreground"
|
|
||||||
onClick={openFilePicker}
|
|
||||||
disabled={isProcessing}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<HugeiconsIcon icon={Loading03Icon} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
|
||||||
)}
|
|
||||||
<span>Upload</span>
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-0">
|
<div className="flex items-center gap-0">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -265,9 +252,7 @@ export function MediaView() {
|
|||||||
currentSortOrder={sortOrder}
|
currentSortOrder={sortOrder}
|
||||||
onSort={({ key }) => {
|
onSort={({ key }) => {
|
||||||
if (sortBy === key) {
|
if (sortBy === key) {
|
||||||
setSortOrder(
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||||
sortOrder === "asc" ? "desc" : "asc",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setSortBy(key);
|
setSortBy(key);
|
||||||
setSortOrder("asc");
|
setSortOrder("asc");
|
||||||
@@ -281,9 +266,7 @@ export function MediaView() {
|
|||||||
currentSortOrder={sortOrder}
|
currentSortOrder={sortOrder}
|
||||||
onSort={({ key }) => {
|
onSort={({ key }) => {
|
||||||
if (sortBy === key) {
|
if (sortBy === key) {
|
||||||
setSortOrder(
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||||
sortOrder === "asc" ? "desc" : "asc",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setSortBy(key);
|
setSortBy(key);
|
||||||
setSortOrder("asc");
|
setSortOrder("asc");
|
||||||
@@ -297,9 +280,7 @@ export function MediaView() {
|
|||||||
currentSortOrder={sortOrder}
|
currentSortOrder={sortOrder}
|
||||||
onSort={({ key }) => {
|
onSort={({ key }) => {
|
||||||
if (sortBy === key) {
|
if (sortBy === key) {
|
||||||
setSortOrder(
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||||
sortOrder === "asc" ? "desc" : "asc",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setSortBy(key);
|
setSortBy(key);
|
||||||
setSortOrder("asc");
|
setSortOrder("asc");
|
||||||
@@ -313,9 +294,7 @@ export function MediaView() {
|
|||||||
currentSortOrder={sortOrder}
|
currentSortOrder={sortOrder}
|
||||||
onSort={({ key }) => {
|
onSort={({ key }) => {
|
||||||
if (sortBy === key) {
|
if (sortBy === key) {
|
||||||
setSortOrder(
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||||
sortOrder === "asc" ? "desc" : "asc",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setSortBy(key);
|
setSortBy(key);
|
||||||
setSortOrder("asc");
|
setSortOrder("asc");
|
||||||
@@ -333,7 +312,16 @@ export function MediaView() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={openFilePicker}
|
||||||
|
disabled={isProcessing}
|
||||||
|
size="sm"
|
||||||
|
className="items-center justify-center gap-1.5 ml-1.5"
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center gap-3 opacity-100 transition-opacity hover:opacity-75">
|
<div className="group flex items-center gap-3 opacity-100 hover:opacity-75">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
|
||||||
export function DeleteProjectDialog({
|
export function DeleteProjectDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -45,25 +49,34 @@ export function DeleteProjectDialog({
|
|||||||
`Delete ${count} projects?`
|
`Delete ${count} projects?`
|
||||||
)}
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
|
||||||
{isSingle
|
|
||||||
? "Are you sure you want to delete this project? This action cannot be undone."
|
|
||||||
: `Are you sure you want to delete these ${count} projects? This action cannot be undone.`}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Warning</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This will permanently delete{" "}
|
||||||
|
{singleName ? `"${singleName}"` : `${count} projects`} and all
|
||||||
|
associated files.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Label className="text-xs font-semibold text-slate-500">
|
||||||
|
Type "DELETE" to confirm
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="DELETE"
|
||||||
|
size="lg"
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
variant="text"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={onConfirm}>
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
Delete
|
Delete project
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export function DraggableItem({
|
|||||||
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
|
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
|
||||||
{preview}
|
{preview}
|
||||||
</div>
|
</div>
|
||||||
<span className="w-full flex-1 truncate text-sm">{name}</span>
|
<span className="w-full flex-1 truncate text-sm text-left">{name}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function EditableTimecode({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-primary cursor-pointer font-mono text-xs tabular-nums",
|
"text-primary cursor-pointer font-mono text-xs tabular-nums",
|
||||||
"hover:bg-muted/50 -mx-1 px-1 transition-colors hover:rounded",
|
"hover:bg-muted/50 -mx-1 px-1 hover:rounded",
|
||||||
disabled && "cursor-default hover:bg-transparent",
|
disabled && "cursor-default hover:bg-transparent",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ export function ExportButton() {
|
|||||||
<button
|
<button
|
||||||
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 transition-all duration-200",
|
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
|
||||||
hasProject
|
hasProject
|
||||||
? "cursor-pointer hover:brightness-95"
|
? "cursor-pointer hover:brightness-105"
|
||||||
: "cursor-not-allowed opacity-50",
|
: "cursor-not-allowed opacity-50",
|
||||||
)}
|
)}
|
||||||
onClick={hasProject ? handleExport : undefined}
|
onClick={hasProject ? handleExport : undefined}
|
||||||
@@ -53,11 +53,11 @@ export function ExportButton() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative flex items-center gap-1.5 rounded-[0.8rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
|
<div className="relative flex items-center gap-1.5 rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
|
||||||
<HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" />
|
<HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" />
|
||||||
<span className="z-50 text-[0.875rem]">Export</span>
|
<span className="z-50 text-[0.875rem]">Export</span>
|
||||||
<div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.8rem] bg-linear-to-t from-white/0 to-white/50">
|
<div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.6rem] bg-linear-to-t from-white/0 to-white/50">
|
||||||
<div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.8rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
|
<div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import type { TProjectMetadata } from "@/types/project";
|
import type { TProjectMetadata } from "@/types/project";
|
||||||
import { formatDate } from "@/utils/date";
|
import { formatDate } from "@/utils/date";
|
||||||
import { formatTimeCode } from "@/lib/time";
|
import { formatTimeCode } from "@/lib/time";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
function InfoRow({
|
function InfoRow({
|
||||||
label,
|
label,
|
||||||
@@ -16,7 +19,7 @@ function InfoRow({
|
|||||||
value: string | React.ReactNode;
|
value: string | React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center py-2 last:pb-0 border-b border-border/50 last:border-b-0">
|
<div className="flex justify-between items-center py-0 last:pb-0">
|
||||||
<span className="text-muted-foreground text-sm">{label}</span>
|
<span className="text-muted-foreground text-sm">{label}</span>
|
||||||
<span className="text-sm font-medium">{value}</span>
|
<span className="text-sm font-medium">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,14 +45,14 @@ export function ProjectInfoDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="truncate max-w-[350px]">
|
<DialogTitle className="truncate max-w-[350px]">
|
||||||
{project.name}
|
{project.name}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<DialogBody className="flex flex-col">
|
||||||
<InfoRow label="Duration" value={durationFormatted} />
|
<InfoRow label="Duration" value={durationFormatted} />
|
||||||
<InfoRow
|
<InfoRow
|
||||||
label="Created"
|
label="Created"
|
||||||
@@ -67,7 +70,13 @@ export function ProjectInfoDialog({
|
|||||||
</code>
|
</code>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ export function PropertyGroup({
|
|||||||
<PropertyItem direction="column" className={cn("gap-3", className)}>
|
<PropertyItem direction="column" className={cn("gap-3", className)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1.5"
|
className="flex items-center gap-1.5 cursor-pointer"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
>
|
>
|
||||||
<PropertyItemLabel className={cn("cursor-pointer", titleClassName)}>
|
<PropertyItemLabel className={cn(titleClassName)}>
|
||||||
{title}
|
{title}
|
||||||
</PropertyItemLabel>
|
</PropertyItemLabel>
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "./property-item";
|
} from "./property-item";
|
||||||
import { ColorPicker } from "@/components/ui/color-picker";
|
import { ColorPicker } from "@/components/ui/color-picker";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { capitalizeFirstLetter, uppercase } from "@/utils/strings";
|
import { capitalizeFirstLetter, uppercase } from "@/utils/string";
|
||||||
import { clamp } from "@/utils/math";
|
import { clamp } from "@/utils/math";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { LayoutGridIcon } from "@hugeicons/core-free-icons";
|
import { LayoutGridIcon } from "@hugeicons/core-free-icons";
|
||||||
@@ -387,7 +387,8 @@ export function TextProperties({
|
|||||||
}
|
}
|
||||||
className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
|
className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
|
||||||
>
|
>
|
||||||
<HugeiconsIcon icon={LayoutGridIcon}
|
<HugeiconsIcon
|
||||||
|
icon={LayoutGridIcon}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground",
|
"text-foreground",
|
||||||
element.backgroundColor === "transparent" &&
|
element.backgroundColor === "transparent" &&
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Keyboard } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +10,8 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
|
|||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -107,19 +106,12 @@ export function ShortcutsDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
|
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
|
||||||
<DialogHeader className="flex-shrink-0 gap-2 p-6 pb-0">
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle>Keyboard shortcuts</DialogTitle>
|
||||||
<Keyboard className="size-5" />
|
|
||||||
Keyboard shortcuts
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Speed up your video editing workflow with these keyboard shortcuts.
|
|
||||||
Click any shortcut key to edit it.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="scrollbar-thin flex-grow overflow-y-auto">
|
<DialogBody className="scrollbar-thin flex-grow overflow-y-auto">
|
||||||
<div className="flex flex-col gap-6 p-6 pt-2">
|
<div className="flex flex-col gap-6">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<div key={category} className="flex flex-col gap-1">
|
<div key={category} className="flex flex-col gap-1">
|
||||||
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||||
@@ -142,9 +134,9 @@ export function ShortcutsDialog({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogBody>
|
||||||
<DialogFooter className="p-4 pt-0">
|
<DialogFooter>
|
||||||
<Button size="sm" variant="destructive" onClick={resetToDefaults}>
|
<Button variant="destructive" onClick={resetToDefaults}>
|
||||||
Reset to default
|
Reset to default
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export function AudioWaveform({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={waveformRef}
|
ref={waveformRef}
|
||||||
className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`}
|
className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`}
|
||||||
style={{ height }}
|
style={{ height }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
Delete02Icon,
|
Delete02Icon,
|
||||||
|
TaskAdd02Icon,
|
||||||
ViewIcon,
|
ViewIcon,
|
||||||
ViewOffSlashIcon,
|
ViewOffSlashIcon,
|
||||||
VolumeHighIcon,
|
VolumeHighIcon,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
canTracktHaveAudio,
|
canTracktHaveAudio,
|
||||||
canTrackBeHidden,
|
canTrackBeHidden,
|
||||||
getTimelineZoomMin,
|
getTimelineZoomMin,
|
||||||
|
isMainTrack,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { TimelineToolbar } from "./timeline-toolbar";
|
import { TimelineToolbar } from "./timeline-toolbar";
|
||||||
import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
|
import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
|
||||||
@@ -48,6 +50,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
|||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||||
import { DragLine } from "./drag-line";
|
import { DragLine } from "./drag-line";
|
||||||
|
import { invokeAction } from "@/lib/actions";
|
||||||
|
|
||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
const tracksContainerHeight = { min: 0, max: 800 };
|
const tracksContainerHeight = { min: 0, max: 800 };
|
||||||
@@ -181,7 +184,7 @@ export function Timeline() {
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={
|
className={
|
||||||
"bg-panel relative flex h-full flex-col overflow-hidden rounded-sm transition-colors duration-200"
|
"bg-panel relative flex h-full flex-col overflow-hidden rounded-sm"
|
||||||
}
|
}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
aria-label="Timeline"
|
aria-label="Timeline"
|
||||||
@@ -267,6 +270,10 @@ export function Timeline() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
||||||
|
{process.env.NODE_ENV === "development" &&
|
||||||
|
isMainTrack(track) && (
|
||||||
|
<div className="bg-red-500 size-1.5 rounded-full" />
|
||||||
|
)}
|
||||||
{canTracktHaveAudio(track) && (
|
{canTracktHaveAudio(track) && (
|
||||||
<TrackToggleIcon
|
<TrackToggleIcon
|
||||||
isOff={track.muted}
|
isOff={track.muted}
|
||||||
@@ -281,7 +288,6 @@ export function Timeline() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canTrackBeHidden(track) && (
|
{canTrackBeHidden(track) && (
|
||||||
<TrackToggleIcon
|
<TrackToggleIcon
|
||||||
isOff={track.hidden}
|
isOff={track.hidden}
|
||||||
@@ -296,7 +302,6 @@ export function Timeline() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TrackIcon track={track} />
|
<TrackIcon track={track} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,6 +402,15 @@ export function Timeline() {
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="z-200 w-40">
|
<ContextMenuContent className="z-200 w-40">
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
invokeAction("paste-copied");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Paste elements
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
Scissors,
|
|
||||||
Trash2,
|
|
||||||
Copy,
|
|
||||||
Search,
|
|
||||||
RefreshCw,
|
|
||||||
EyeOff,
|
|
||||||
Eye,
|
|
||||||
Volume2,
|
|
||||||
VolumeX,
|
|
||||||
ArrowUpDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||||
import AudioWaveform from "./audio-waveform";
|
import AudioWaveform from "./audio-waveform";
|
||||||
@@ -39,9 +27,35 @@ import type {
|
|||||||
} from "@/types/timeline";
|
} from "@/types/timeline";
|
||||||
import type { MediaAsset } from "@/types/assets";
|
import type { MediaAsset } from "@/types/assets";
|
||||||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||||
import { type TAction, invokeAction } from "@/lib/actions";
|
import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import {
|
||||||
|
ScissorIcon,
|
||||||
|
Delete02Icon,
|
||||||
|
Copy01Icon,
|
||||||
|
ViewIcon,
|
||||||
|
ViewOffSlashIcon,
|
||||||
|
VolumeHighIcon,
|
||||||
|
VolumeOffIcon,
|
||||||
|
VolumeMute02Icon,
|
||||||
|
Search01Icon,
|
||||||
|
Exchange01Icon,
|
||||||
|
} from "@hugeicons/core-free-icons";
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
import { uppercase } from "@/utils/string";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
function getDisplayShortcut(action: TAction) {
|
||||||
|
const { defaultShortcuts } = getActionDefinition(action);
|
||||||
|
if (!defaultShortcuts?.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return uppercase({
|
||||||
|
string: defaultShortcuts[0].replace("+", " "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface TimelineElementProps {
|
interface TimelineElementProps {
|
||||||
element: TimelineElementType;
|
element: TimelineElementType;
|
||||||
@@ -112,17 +126,6 @@ export function TimelineElement({
|
|||||||
displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||||
const elementLeft = displayedStartTime * 50 * zoomLevel;
|
const elementLeft = displayedStartTime * 50 * zoomLevel;
|
||||||
|
|
||||||
const handleAction = ({
|
|
||||||
action,
|
|
||||||
event,
|
|
||||||
}: {
|
|
||||||
action: TAction;
|
|
||||||
event: React.MouseEvent;
|
|
||||||
}) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
invokeAction(action);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
|
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (hasMediaId(element)) {
|
if (hasMediaId(element)) {
|
||||||
@@ -162,31 +165,16 @@ export function TimelineElement({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="z-200">
|
<ContextMenuContent className="z-200 w-64">
|
||||||
<ContextMenuItem
|
<ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}>
|
||||||
onClick={(event) => handleAction({ action: "split-selected", event })}
|
Split
|
||||||
>
|
</ActionMenuItem>
|
||||||
<Scissors className="mr-2 size-4" />
|
<CopyMenuItem />
|
||||||
{selectedElements.length > 1 && isCurrentElementSelected
|
|
||||||
? `Split ${selectedElements.length} elements at playhead`
|
|
||||||
: "Split at playhead"}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<CopyMenuItem
|
|
||||||
isMultipleSelected={selectedElements.length > 1}
|
|
||||||
isCurrentElementSelected={isCurrentElementSelected}
|
|
||||||
selectedCount={selectedElements.length}
|
|
||||||
onClick={(event) => handleAction({ action: "copy-selected", event })}
|
|
||||||
/>
|
|
||||||
{canElementHaveAudio(element) && hasAudio && (
|
{canElementHaveAudio(element) && hasAudio && (
|
||||||
<MuteMenuItem
|
<MuteMenuItem
|
||||||
element={element}
|
|
||||||
isMultipleSelected={selectedElements.length > 1}
|
isMultipleSelected={selectedElements.length > 1}
|
||||||
isCurrentElementSelected={isCurrentElementSelected}
|
isCurrentElementSelected={isCurrentElementSelected}
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
selectedCount={selectedElements.length}
|
|
||||||
onClick={(event) =>
|
|
||||||
handleAction({ action: "toggle-elements-muted-selected", event })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canElementBeHidden(element) && (
|
{canElementBeHidden(element) && (
|
||||||
@@ -194,36 +182,26 @@ export function TimelineElement({
|
|||||||
element={element}
|
element={element}
|
||||||
isMultipleSelected={selectedElements.length > 1}
|
isMultipleSelected={selectedElements.length > 1}
|
||||||
isCurrentElementSelected={isCurrentElementSelected}
|
isCurrentElementSelected={isCurrentElementSelected}
|
||||||
selectedCount={selectedElements.length}
|
|
||||||
onClick={(event) =>
|
|
||||||
handleAction({
|
|
||||||
action: "toggle-elements-visibility-selected",
|
|
||||||
event,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedElements.length === 1 && (
|
{selectedElements.length === 1 && (
|
||||||
<ContextMenuItem
|
<ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
|
||||||
onClick={(event) =>
|
Duplicate
|
||||||
handleAction({ action: "duplicate-selected", event })
|
</ActionMenuItem>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Copy className="mr-2 size-4" />
|
|
||||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
|
||||||
</ContextMenuItem>
|
|
||||||
)}
|
)}
|
||||||
{selectedElements.length === 1 && hasMediaId(element) && (
|
{selectedElements.length === 1 && hasMediaId(element) && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
|
icon={<HugeiconsIcon icon={Search01Icon} />}
|
||||||
onClick={(event) => handleRevealInMedia({ event })}
|
onClick={(event) => handleRevealInMedia({ event })}
|
||||||
>
|
>
|
||||||
<Search className="mr-2 size-4" />
|
Reveal media
|
||||||
Reveal in media
|
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem disabled>
|
<ContextMenuItem
|
||||||
<RefreshCw className="mr-2 size-4" />
|
icon={<HugeiconsIcon icon={Exchange01Icon} />}
|
||||||
Replace clip (Coming soon)
|
disabled
|
||||||
|
>
|
||||||
|
Replace media
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -233,9 +211,6 @@ export function TimelineElement({
|
|||||||
isCurrentElementSelected={isCurrentElementSelected}
|
isCurrentElementSelected={isCurrentElementSelected}
|
||||||
elementType={element.type}
|
elementType={element.type}
|
||||||
selectedCount={selectedElements.length}
|
selectedCount={selectedElements.length}
|
||||||
onClick={(event) =>
|
|
||||||
handleAction({ action: "delete-selected", event })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
@@ -300,9 +275,15 @@ function ElementInner({
|
|||||||
: canElementBeHidden(element) && element.hidden) && (
|
: canElementBeHidden(element) && element.hidden) && (
|
||||||
<div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
|
<div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
|
||||||
{hasAudio ? (
|
{hasAudio ? (
|
||||||
<VolumeX className="size-6 text-white" />
|
<HugeiconsIcon
|
||||||
|
icon={VolumeHighIcon}
|
||||||
|
className="size-6 text-white"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EyeOff className="size-6 text-white" />
|
<HugeiconsIcon
|
||||||
|
icon={VolumeOffIcon}
|
||||||
|
className="size-6 text-white"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -463,66 +444,38 @@ function ElementContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyMenuItem({
|
function CopyMenuItem() {
|
||||||
isMultipleSelected,
|
|
||||||
isCurrentElementSelected,
|
|
||||||
selectedCount,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
isMultipleSelected: boolean;
|
|
||||||
isCurrentElementSelected: boolean;
|
|
||||||
selectedCount: number;
|
|
||||||
onClick: (e: React.MouseEvent) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<ContextMenuItem onClick={onClick}>
|
<ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
|
||||||
<Copy className="mr-2 size-4" />
|
Copy
|
||||||
{isMultipleSelected && isCurrentElementSelected
|
</ActionMenuItem>
|
||||||
? `Copy ${selectedCount} elements`
|
|
||||||
: "Copy element"}
|
|
||||||
</ContextMenuItem>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MuteMenuItem({
|
function MuteMenuItem({
|
||||||
element,
|
|
||||||
isMultipleSelected,
|
isMultipleSelected,
|
||||||
isCurrentElementSelected,
|
isCurrentElementSelected,
|
||||||
isMuted,
|
isMuted,
|
||||||
selectedCount,
|
|
||||||
onClick,
|
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElementType;
|
|
||||||
isMultipleSelected: boolean;
|
isMultipleSelected: boolean;
|
||||||
isCurrentElementSelected: boolean;
|
isCurrentElementSelected: boolean;
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
selectedCount: number;
|
|
||||||
onClick: (e: React.MouseEvent) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
if (isMultipleSelected && isCurrentElementSelected) {
|
if (isMultipleSelected && isCurrentElementSelected) {
|
||||||
return <VolumeX className="mr-2 size-4" />;
|
return <HugeiconsIcon icon={VolumeMute02Icon} />;
|
||||||
}
|
}
|
||||||
return isMuted ? (
|
return isMuted ? (
|
||||||
<Volume2 className="mr-2 size-4" />
|
<HugeiconsIcon icon={VolumeHighIcon} />
|
||||||
) : (
|
) : (
|
||||||
<VolumeX className="mr-2 size-4" />
|
<HugeiconsIcon icon={VolumeOffIcon} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLabel = () => {
|
|
||||||
if (isMultipleSelected && isCurrentElementSelected) {
|
|
||||||
return `Toggle mute ${selectedCount} elements`;
|
|
||||||
}
|
|
||||||
const suffix = element.type === "text" ? "text" : "clip";
|
|
||||||
return isMuted ? `Unmute ${suffix}` : `Mute ${suffix}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenuItem onClick={onClick}>
|
<ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
|
||||||
{getIcon()}
|
{isMuted ? "Unmute" : "Mute"}
|
||||||
<span>{getLabel()}</span>
|
</ActionMenuItem>
|
||||||
</ContextMenuItem>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,41 +483,28 @@ function VisibilityMenuItem({
|
|||||||
element,
|
element,
|
||||||
isMultipleSelected,
|
isMultipleSelected,
|
||||||
isCurrentElementSelected,
|
isCurrentElementSelected,
|
||||||
selectedCount,
|
|
||||||
onClick,
|
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElementType;
|
element: TimelineElementType;
|
||||||
isMultipleSelected: boolean;
|
isMultipleSelected: boolean;
|
||||||
isCurrentElementSelected: boolean;
|
isCurrentElementSelected: boolean;
|
||||||
selectedCount: number;
|
|
||||||
onClick: (e: React.MouseEvent) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const isHidden = canElementBeHidden(element) && element.hidden;
|
const isHidden = canElementBeHidden(element) && element.hidden;
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
if (isMultipleSelected && isCurrentElementSelected) {
|
if (isMultipleSelected && isCurrentElementSelected) {
|
||||||
return <EyeOff className="mr-2 size-4" />;
|
return <HugeiconsIcon icon={ViewOffSlashIcon} />;
|
||||||
}
|
}
|
||||||
return isHidden ? (
|
return isHidden ? (
|
||||||
<Eye className="mr-2 size-4" />
|
<HugeiconsIcon icon={ViewIcon} />
|
||||||
) : (
|
) : (
|
||||||
<EyeOff className="mr-2 size-4" />
|
<HugeiconsIcon icon={ViewOffSlashIcon} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLabel = () => {
|
|
||||||
if (isMultipleSelected && isCurrentElementSelected) {
|
|
||||||
return `Toggle visibility ${selectedCount} elements`;
|
|
||||||
}
|
|
||||||
const suffix = element.type === "text" ? "text" : "clip";
|
|
||||||
return isHidden ? `Show ${suffix}` : `Hide ${suffix}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenuItem onClick={onClick}>
|
<ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}>
|
||||||
{getIcon()}
|
{isHidden ? "Show" : "Hide"}
|
||||||
<span>{getLabel()}</span>
|
</ActionMenuItem>
|
||||||
</ContextMenuItem>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,23 +513,42 @@ function DeleteMenuItem({
|
|||||||
isCurrentElementSelected,
|
isCurrentElementSelected,
|
||||||
elementType,
|
elementType,
|
||||||
selectedCount,
|
selectedCount,
|
||||||
onClick,
|
|
||||||
}: {
|
}: {
|
||||||
isMultipleSelected: boolean;
|
isMultipleSelected: boolean;
|
||||||
isCurrentElementSelected: boolean;
|
isCurrentElementSelected: boolean;
|
||||||
elementType: TimelineElementType["type"];
|
elementType: TimelineElementType["type"];
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
onClick: (e: React.MouseEvent) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ContextMenuItem
|
<ActionMenuItem
|
||||||
onClick={onClick}
|
action="delete-selected"
|
||||||
className="text-destructive focus:text-destructive"
|
variant="destructive"
|
||||||
|
icon={<HugeiconsIcon icon={Delete02Icon} />}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 size-4" />
|
|
||||||
{isMultipleSelected && isCurrentElementSelected
|
{isMultipleSelected && isCurrentElementSelected
|
||||||
? `Delete ${selectedCount} elements`
|
? `Delete ${selectedCount} elements`
|
||||||
: `Delete ${elementType === "text" ? "text" : "clip"}`}
|
: `Delete ${elementType === "text" ? "text" : "clip"}`}
|
||||||
|
</ActionMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionMenuItem({
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
|
||||||
|
action: TAction;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
invokeAction(action);
|
||||||
|
}}
|
||||||
|
textRight={getDisplayShortcut(action)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +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 {
|
import { SkipBack, SplitSquareHorizontal } from "lucide-react";
|
||||||
SkipBack,
|
|
||||||
SplitSquareHorizontal,
|
|
||||||
LayersIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
SplitButton,
|
SplitButton,
|
||||||
SplitButtonLeft,
|
SplitButtonLeft,
|
||||||
@@ -38,10 +34,6 @@ import {
|
|||||||
PauseIcon,
|
PauseIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
Copy01Icon,
|
Copy01Icon,
|
||||||
ArrowLeft03Icon,
|
|
||||||
ArrowRight03Icon,
|
|
||||||
LayoutAlignLeftIcon,
|
|
||||||
LayoutAlignRightIcon,
|
|
||||||
AlignLeftIcon,
|
AlignLeftIcon,
|
||||||
AlignRightIcon,
|
AlignRightIcon,
|
||||||
Layers01Icon,
|
Layers01Icon,
|
||||||
@@ -136,7 +128,7 @@ function ToolbarLeftSection() {
|
|||||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||||
tooltip="Split element"
|
tooltip="Split element"
|
||||||
onClick={({ event }) =>
|
onClick={({ event }) =>
|
||||||
handleAction({ action: "split-selected", event })
|
handleAction({ action: "split", event })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -144,7 +136,7 @@ function ToolbarLeftSection() {
|
|||||||
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
|
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
|
||||||
tooltip="Split left"
|
tooltip="Split left"
|
||||||
onClick={({ event }) =>
|
onClick={({ event }) =>
|
||||||
handleAction({ action: "split-selected-right", event })
|
handleAction({ action: "split-left", event })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -152,7 +144,7 @@ function ToolbarLeftSection() {
|
|||||||
icon={<HugeiconsIcon icon={AlignRightIcon} />}
|
icon={<HugeiconsIcon icon={AlignRightIcon} />}
|
||||||
tooltip="Split right"
|
tooltip="Split right"
|
||||||
onClick={({ event }) =>
|
onClick={({ event }) =>
|
||||||
handleAction({ action: "split-selected-left", event })
|
handleAction({ action: "split-right", event })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
|
|||||||
import { FaGithub } from "react-icons/fa6";
|
import { FaGithub } from "react-icons/fa6";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
|
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
|
||||||
import { capitalizeFirstLetter } from "@/utils/strings";
|
import { capitalizeFirstLetter } from "@/utils/string";
|
||||||
|
|
||||||
type Category = "resources" | "company";
|
type Category = "resources" | "company";
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function Header() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/projects">
|
<Link href="/projects">
|
||||||
<Button className="text-sm">
|
<Button variant="foreground" className="text-sm">
|
||||||
Projects
|
Projects
|
||||||
<ArrowRight className="size-4" />
|
<ArrowRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ export function Hero() {
|
|||||||
<div className="mt-8 flex justify-center gap-8">
|
<div className="mt-8 flex justify-center gap-8">
|
||||||
<Link href="/projects">
|
<Link href="/projects">
|
||||||
<Button
|
<Button
|
||||||
|
variant="foreground"
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="bg-foreground h-11 px-6 text-base"
|
className="h-11 text-base"
|
||||||
>
|
>
|
||||||
Try early beta
|
Try early beta
|
||||||
<ArrowRight className="ml-0.5" />
|
<ArrowRight className="ml-0.5" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
|
||||||
export function RenameProjectDialog({
|
export function RenameProjectDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -34,12 +36,11 @@ export function RenameProjectDialog({
|
|||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Rename Project</DialogTitle>
|
<DialogTitle>Rename project</DialogTitle>
|
||||||
<DialogDescription>
|
|
||||||
Enter a new name for your project.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody className="gap-3">
|
||||||
|
<Label>New name</Label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
@@ -50,8 +51,8 @@ export function RenameProjectDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Enter a new name"
|
placeholder="Enter a new name"
|
||||||
className="bg-background border-border mt-0 border-2"
|
|
||||||
/>
|
/>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ const AccordionTrigger = React.forwardRef<
|
|||||||
<AccordionPrimitive.Trigger
|
<AccordionPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 cursor-pointer items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
"flex flex-1 cursor-pointer items-center justify-between py-4 text-left text-sm font-medium hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronDown className="text-muted-foreground size-4 shrink-0 transition-transform duration-200" />
|
<ChevronDown className="text-muted-foreground size-4 shrink-0" />
|
||||||
</AccordionPrimitive.Trigger>
|
</AccordionPrimitive.Trigger>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionPrimitive.Header>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
import { Alert02Icon } from "@hugeicons/core-free-icons";
|
||||||
|
|
||||||
const alertVariants = cva(
|
const alertVariants = cva(
|
||||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
"relative w-full rounded-lg border p-5 py-4.5 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-destructive [&>svg~*]:pl-7",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-background text-foreground",
|
default: "bg-background text-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
"bg-destructive/5 border-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -22,13 +24,18 @@ const alertVariants = cva(
|
|||||||
const Alert = React.forwardRef<
|
const Alert = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
>(({ className, variant, ...props }, ref) => (
|
>(({ className, variant, children, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role="alert"
|
role="alert"
|
||||||
className={cn(alertVariants({ variant }), className)}
|
className={cn(alertVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{variant === "destructive" && (
|
||||||
|
<HugeiconsIcon icon={Alert02Icon} className="size-5 text-destructive mt-0.5" />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
));
|
));
|
||||||
Alert.displayName = "Alert";
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
@@ -38,7 +45,7 @@ const AlertTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<h5
|
<h5
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("mb-1 leading-none font-medium tracking-tight", className)}
|
className={cn("mb-2 text-base leading-none font-semibold tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const BreadcrumbLink = React.forwardRef<
|
|||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("hover:text-foreground transition-colors", className)}
|
className={cn("hover:text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,34 +5,32 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
|
"bg-primary text-primary-foreground hover:bg-primary-hover",
|
||||||
foreground:
|
foreground:
|
||||||
"bg-background text-foreground",
|
"bg-foreground text-background hover:bg-foreground/90",
|
||||||
primary:
|
|
||||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
|
||||||
"primary-gradient":
|
"primary-gradient":
|
||||||
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
|
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
"destructive-foreground":
|
"destructive-foreground":
|
||||||
"border bg-background hover:bg-destructive/15 text-destructive",
|
"border bg-background hover:bg-destructive/15 text-destructive",
|
||||||
outline:
|
outline:
|
||||||
"border border-border bg-transparent transition-colors hover:bg-accent",
|
"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 hover:bg-foreground/15 border border-input",
|
||||||
text: "bg-transparent rounded-none !opacity-100",
|
text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
|
||||||
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 px-4 py-2",
|
default: "h-9.5 px-4 py-2",
|
||||||
sm: "h-8 rounded-full px-3 text-xs",
|
sm: "h-8 px-3 text-xs",
|
||||||
lg: "h-10 rounded-full p-5 px-6",
|
lg: "h-10 p-5 px-6",
|
||||||
icon: "size-7 rounded-sm",
|
icon: "size-7",
|
||||||
text: "p-0",
|
text: "p-0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer bg-background peer focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 shadow-xs rounded-[0.35rem] border focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
"cursor-pointer bg-background peer focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 shadow-xs rounded-sm border focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
className="size-6 cursor-pointer rounded-full transition-all hover:ring-2 hover:ring-white/20"
|
className="size-6 cursor-pointer rounded-full hover:ring-2 hover:ring-white/20"
|
||||||
style={{ backgroundColor: `#${value}` }}
|
style={{ backgroundColor: `#${value}` }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
import { Tick02Icon, ArrowRightIcon, CircleIcon } from "@hugeicons/core-free-icons"
|
||||||
|
|
||||||
const ContextMenu = ContextMenuPrimitive.Root;
|
const ContextMenu = ContextMenuPrimitive.Root;
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
|
|||||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const contextMenuItemVariants = cva(
|
const contextMenuItemVariants = cva(
|
||||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-hidden transition-opacity 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.5 px-4 py-1.5 last:pb-1 text-base outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "focus:opacity-65 focus:text-accent-foreground",
|
default: "focus:bg-accent/35 focus:text-accent-foreground [&_svg]:text-muted-foreground",
|
||||||
destructive: "text-destructive focus:text-destructive/80",
|
destructive: "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -39,20 +39,22 @@ const ContextMenuSubTrigger = React.forwardRef<
|
|||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
|
icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
>(({ className, inset, children, variant = "default", icon, ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.SubTrigger
|
<ContextMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
contextMenuItemVariants({ variant }),
|
contextMenuItemVariants({ variant }),
|
||||||
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
|
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto" />
|
<HugeiconsIcon icon={ArrowRightIcon} className="ml-auto text-muted-foreground/80" />
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||||
@@ -64,7 +66,7 @@ const ContextMenuSubContent = React.forwardRef<
|
|||||||
<ContextMenuPrimitive.SubContent
|
<ContextMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -80,8 +82,7 @@ const ContextMenuContent = React.forwardRef<
|
|||||||
<ContextMenuPrimitive.Content
|
<ContextMenuPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-md",
|
"bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -95,8 +96,10 @@ const ContextMenuItem = React.forwardRef<
|
|||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
textRight?: string;
|
||||||
}
|
}
|
||||||
>(({ className, inset, variant = "default", ...props }, ref) => (
|
>(({ className, inset, variant = "default", icon, children, textRight, ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.Item
|
<ContextMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -105,7 +108,15 @@ const ContextMenuItem = React.forwardRef<
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{icon && <span className="[&_svg]:size-4 [&_svg]:shrink-0">{icon}</span>}
|
||||||
|
{children}
|
||||||
|
{textRight && (
|
||||||
|
<span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5">
|
||||||
|
{textRight}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ContextMenuPrimitive.Item>
|
||||||
));
|
));
|
||||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
@@ -113,8 +124,9 @@ const ContextMenuCheckboxItem = React.forwardRef<
|
|||||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
|
icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
>(({ className, children, checked, variant = "default", icon, ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.CheckboxItem
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
|
className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
|
||||||
@@ -123,9 +135,10 @@ const ContextMenuCheckboxItem = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
<Check className="size-4" />
|
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
|
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
|
||||||
{children}
|
{children}
|
||||||
</ContextMenuPrimitive.CheckboxItem>
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
));
|
));
|
||||||
@@ -136,8 +149,9 @@ const ContextMenuRadioItem = React.forwardRef<
|
|||||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
|
||||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
|
icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
>(({ className, children, variant = "default", icon, ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.RadioItem
|
<ContextMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
|
className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
|
||||||
@@ -145,9 +159,10 @@ const ContextMenuRadioItem = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
<Circle className="size-2 fill-current" />
|
<HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" />
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
|
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
|
||||||
{children}
|
{children}
|
||||||
</ContextMenuPrimitive.RadioItem>
|
</ContextMenuPrimitive.RadioItem>
|
||||||
));
|
));
|
||||||
@@ -157,17 +172,21 @@ const ContextMenuLabel = React.forwardRef<
|
|||||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, icon, children, ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.Label
|
<ContextMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
"flex items-center gap-2.5 px-4 py-1.5 text-sm font-semibold text-foreground",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.Label>
|
||||||
));
|
));
|
||||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
@@ -177,7 +196,7 @@ const ContextMenuSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.Separator
|
<ContextMenuPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("bg-foreground/10 -mx-1 my-1 h-px", className)}
|
className={cn("bg-border my-2 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -189,7 +208,7 @@ const ContextMenuShortcut = ({
|
|||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
className={cn("ml-auto text-xs tracking-widest text-muted-foreground opacity-60", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import { ScrollArea } from "./scroll-area";
|
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root;
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
@@ -22,7 +20,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-250 bg-black/50",
|
"fixed inset-0 z-250 backdrop-blur-sm bg-black/10",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,7 +37,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
"bg-popover fixed top-[50%] left-[50%] z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onCloseAutoFocus={(e) => {
|
onCloseAutoFocus={(e) => {
|
||||||
@@ -49,8 +47,8 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-6 right-6 cursor-pointer opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
<X className="size-4" />
|
<X className="size-5 text-muted-foreground" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
@@ -63,19 +61,27 @@ const DialogHeader = ({
|
|||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col space-y-2 text-left", className)}
|
className={cn("flex flex-col space-y-2 text-left border-b p-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
DialogHeader.displayName = "DialogHeader";
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogBody = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("p-6 flex flex-col gap-6", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogBody.displayName = "DialogBody";
|
||||||
|
|
||||||
const DialogFooter = ({
|
const DialogFooter = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
|
"flex gap-3 flex-col-reverse sm:flex-row sm:justify-end p-6 py-5 border-t",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -118,6 +124,7 @@ export {
|
|||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
DialogBody,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
|||||||
@@ -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-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-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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -88,7 +88,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
|
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-2 shadow-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const InputOTPSlot = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input relative flex aspect-square min-w-[36px] flex-1 items-center justify-center border text-lg shadow-xs transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
"border-input relative flex aspect-square min-w-[36px] flex-1 items-center justify-center border text-lg shadow-xs first:rounded-l-md first:border-l last:rounded-r-md",
|
||||||
isActive && "ring-ring z-10 ring-1",
|
isActive && "ring-ring z-10 ring-1",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function InputWithBack({
|
|||||||
return (
|
return (
|
||||||
<div ref={setContainerRef} className="relative w-full">
|
<div ref={setContainerRef} className="relative w-full">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-75"
|
className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
|
||||||
initial={{
|
initial={{
|
||||||
x: isExpanded ? 0 : buttonOffset,
|
x: isExpanded ? 0 : buttonOffset,
|
||||||
opacity: isExpanded ? 1 : 0.5,
|
opacity: isExpanded ? 1 : 0.5,
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ import { forwardRef, type ComponentProps } from "react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const inputVariants = cva(
|
const inputVariants = cva(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-border bg-background flex w-full min-w-0 rounded-full border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"file:text-foreground placeholder:text-muted-foreground border-border bg-background flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-4 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary focus-visible:ring-primary/10",
|
||||||
|
destructive: "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
"h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
|
"h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
|
||||||
@@ -20,6 +24,7 @@ const inputVariants = cva(
|
|||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
size: "default",
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -40,6 +45,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className,
|
className,
|
||||||
type,
|
type,
|
||||||
size,
|
size,
|
||||||
|
variant,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
showPassword,
|
showPassword,
|
||||||
onShowPasswordChange,
|
onShowPasswordChange,
|
||||||
@@ -79,6 +85,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
inputVariants({
|
inputVariants({
|
||||||
size,
|
size,
|
||||||
className: cn(paddingRight, className),
|
className: cn(paddingRight, className),
|
||||||
|
variant,
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -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 font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
"text-sm text-muted-foreground font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
);
|
);
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
|||||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||||
|
|
||||||
const navigationMenuTriggerStyle = cva(
|
const navigationMenuTriggerStyle = cva(
|
||||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50",
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50",
|
||||||
);
|
);
|
||||||
|
|
||||||
const NavigationMenuTrigger = React.forwardRef<
|
const NavigationMenuTrigger = React.forwardRef<
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const Progress = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ProgressPrimitive.Indicator
|
<ProgressPrimitive.Indicator
|
||||||
className="bg-foreground size-full flex-1 transition-all"
|
className="bg-foreground size-full flex-1"
|
||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
/>
|
/>
|
||||||
</ProgressPrimitive.Root>
|
</ProgressPrimitive.Root>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const Slider = React.forwardRef<
|
|||||||
<SliderPrimitive.Track className="bg-primary/20 relative h-1.5 w-full grow overflow-hidden rounded-full">
|
<SliderPrimitive.Track className="bg-primary/20 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 transition-colors 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" />
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
));
|
));
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|||||||
@@ -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-full border",
|
"border-input bg-panel-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 transition-colors",
|
"bg-panel-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",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
"peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
"bg-background pointer-events-none block size-4 rounded-full shadow-lg ring-0 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const TableRow = React.forwardRef<
|
|||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef<
|
|||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
const toastVariants = cva(
|
const toastVariants = cva(
|
||||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full",
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -62,7 +62,7 @@ const ToastAction = React.forwardRef<
|
|||||||
<ToastPrimitives.Action
|
<ToastPrimitives.Action
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
|
"hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -77,7 +77,7 @@ const ToastClose = React.forwardRef<
|
|||||||
<ToastPrimitives.Close
|
<ToastPrimitives.Close
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground/50 hover:text-foreground focus-visible::ring-1 group-[.destructive]:text-muted-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:group-[.destructive]:text-red-50 focus:opacity-100 focus:outline-hidden focus-visible:group-[.destructive]:ring-red-400 focus-visible:group-[.destructive]:ring-offset-red-600",
|
"text-foreground/50 hover:text-foreground focus-visible::ring-1 group-[.destructive]:text-muted-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 group-hover:opacity-100 hover:group-[.destructive]:text-red-50 focus:opacity-100 focus:outline-hidden focus-visible:group-[.destructive]:ring-red-400 focus-visible:group-[.destructive]:ring-offset-red-600",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
toast-close=""
|
toast-close=""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
const toggleVariants = cva(
|
const toggleVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -27,12 +27,6 @@ export const EXTERNAL_TOOLS: ExternalTool[] = [
|
|||||||
url: "https://marblecms.com?utm_source=opencut",
|
url: "https://marblecms.com?utm_source=opencut",
|
||||||
icon: OcMarbleIcon,
|
icon: OcMarbleIcon,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Vercel",
|
|
||||||
description: "Platform where we deploy and host OpenCut",
|
|
||||||
url: "https://vercel.com?utm_source=opencut",
|
|
||||||
icon: OcVercelIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Databuddy",
|
name: "Databuddy",
|
||||||
description: "GDPR compliant analytics and user insights for OpenCut",
|
description: "GDPR compliant analytics and user insights for OpenCut",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RendererManager } from "./managers/renderer-manager";
|
|||||||
import { CommandManager } from "./managers/commands";
|
import { CommandManager } from "./managers/commands";
|
||||||
import { SaveManager } from "./managers/save-manager";
|
import { SaveManager } from "./managers/save-manager";
|
||||||
import { AudioManager } from "./managers/audio-manager";
|
import { AudioManager } from "./managers/audio-manager";
|
||||||
|
import { SelectionManager } from "./managers/selection-manager";
|
||||||
|
|
||||||
export class EditorCore {
|
export class EditorCore {
|
||||||
private static instance: EditorCore | null = null;
|
private static instance: EditorCore | null = null;
|
||||||
@@ -20,6 +21,7 @@ export class EditorCore {
|
|||||||
public readonly renderer: RendererManager;
|
public readonly renderer: RendererManager;
|
||||||
public readonly save: SaveManager;
|
public readonly save: SaveManager;
|
||||||
public readonly audio: AudioManager;
|
public readonly audio: AudioManager;
|
||||||
|
public readonly selection: SelectionManager;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.command = new CommandManager();
|
this.command = new CommandManager();
|
||||||
@@ -31,6 +33,7 @@ export class EditorCore {
|
|||||||
this.renderer = new RendererManager(this);
|
this.renderer = new RendererManager(this);
|
||||||
this.save = new SaveManager(this);
|
this.save = new SaveManager(this);
|
||||||
this.audio = new AudioManager(this);
|
this.audio = new AudioManager(this);
|
||||||
|
this.selection = new SelectionManager(this);
|
||||||
this.save.start();
|
this.save.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { EditorCore } from "@/core";
|
||||||
|
|
||||||
|
type ElementRef = { trackId: string; elementId: string };
|
||||||
|
|
||||||
|
export class SelectionManager {
|
||||||
|
private selectedElements: ElementRef[] = [];
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(editor: EditorCore) {
|
||||||
|
void editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedElements(): ElementRef[] {
|
||||||
|
return this.selectedElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
|
||||||
|
this.selectedElements = elements;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection(): void {
|
||||||
|
this.selectedElements = [];
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
this.listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,10 +142,10 @@ export class TimelineManager {
|
|||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
splitTime: number;
|
splitTime: number;
|
||||||
retainSide?: "both" | "left" | "right";
|
retainSide?: "both" | "left" | "right";
|
||||||
}): string[] {
|
}): { trackId: string; elementId: string }[] {
|
||||||
const command = new SplitElementsCommand(elements, splitTime, retainSide);
|
const command = new SplitElementsCommand(elements, splitTime, retainSide);
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
return command.splitElementIds;
|
return command.getRightSideElements();
|
||||||
}
|
}
|
||||||
|
|
||||||
getTotalDuration(): number {
|
getTotalDuration(): number {
|
||||||
@@ -184,9 +184,10 @@ export class TimelineManager {
|
|||||||
}: {
|
}: {
|
||||||
time: number;
|
time: number;
|
||||||
clipboardItems: ClipboardItem[];
|
clipboardItems: ClipboardItem[];
|
||||||
}): void {
|
}): { trackId: string; elementId: string }[] {
|
||||||
const command = new PasteCommand(time, clipboardItems);
|
const command = new PasteCommand(time, clipboardItems);
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
|
return command.getPastedElements();
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteElements({
|
deleteElements({
|
||||||
@@ -230,9 +231,10 @@ export class TimelineManager {
|
|||||||
elements,
|
elements,
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
}): void {
|
}): { trackId: string; elementId: string }[] {
|
||||||
const command = new DuplicateElementsCommand({ elements });
|
const command = new DuplicateElementsCommand({ elements });
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
|
return command.getDuplicatedElements();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleElementsVisibility({
|
toggleElementsVisibility({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
|||||||
import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
||||||
import { useEditor } from "../use-editor";
|
import { useEditor } from "../use-editor";
|
||||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
import { useElementSelection } from "../timeline/element/use-element-selection";
|
||||||
|
import { getElementsAtTime } from "@/lib/timeline";
|
||||||
|
|
||||||
export function useEditorActions() {
|
export function useEditorActions() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
@@ -122,40 +123,73 @@ export function useEditorActions() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"split-selected",
|
"split",
|
||||||
() => {
|
() => {
|
||||||
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const elementsToSplit =
|
||||||
|
selectedElements.length > 0
|
||||||
|
? selectedElements
|
||||||
|
: getElementsAtTime({
|
||||||
|
tracks: editor.timeline.getTracks(),
|
||||||
|
time: currentTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (elementsToSplit.length === 0) return;
|
||||||
|
|
||||||
editor.timeline.splitElements({
|
editor.timeline.splitElements({
|
||||||
elements: selectedElements,
|
elements: elementsToSplit,
|
||||||
splitTime: editor.playback.getCurrentTime(),
|
splitTime: currentTime,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"split-selected-left",
|
"split-left",
|
||||||
() => {
|
() => {
|
||||||
editor.timeline.splitElements({
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
elements: selectedElements,
|
const elementsToSplit =
|
||||||
splitTime: editor.playback.getCurrentTime(),
|
selectedElements.length > 0
|
||||||
retainSide: "left",
|
? selectedElements
|
||||||
|
: getElementsAtTime({
|
||||||
|
tracks: editor.timeline.getTracks(),
|
||||||
|
time: currentTime,
|
||||||
});
|
});
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
useActionHandler(
|
if (elementsToSplit.length === 0) return;
|
||||||
"split-selected-right",
|
|
||||||
() => {
|
|
||||||
editor.timeline.splitElements({
|
editor.timeline.splitElements({
|
||||||
elements: selectedElements,
|
elements: elementsToSplit,
|
||||||
splitTime: editor.playback.getCurrentTime(),
|
splitTime: currentTime,
|
||||||
retainSide: "right",
|
retainSide: "right",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useActionHandler(
|
||||||
|
"split-right",
|
||||||
|
() => {
|
||||||
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
const elementsToSplit =
|
||||||
|
selectedElements.length > 0
|
||||||
|
? selectedElements
|
||||||
|
: getElementsAtTime({
|
||||||
|
tracks: editor.timeline.getTracks(),
|
||||||
|
time: currentTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (elementsToSplit.length === 0) return;
|
||||||
|
|
||||||
|
editor.timeline.splitElements({
|
||||||
|
elements: elementsToSplit,
|
||||||
|
splitTime: currentTime,
|
||||||
|
retainSide: "left",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"delete-selected",
|
"delete-selected",
|
||||||
() => {
|
() => {
|
||||||
@@ -186,7 +220,10 @@ export function useEditorActions() {
|
|||||||
useActionHandler(
|
useActionHandler(
|
||||||
"duplicate-selected",
|
"duplicate-selected",
|
||||||
() => {
|
() => {
|
||||||
editor.timeline.duplicateElements({ elements: selectedElements });
|
const duplicatedElements = editor.timeline.duplicateElements({
|
||||||
|
elements: selectedElements,
|
||||||
|
});
|
||||||
|
setElementSelection({ elements: duplicatedElements });
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -238,14 +275,16 @@ export function useEditorActions() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"paste-selected",
|
"paste-copied",
|
||||||
() => {
|
() => {
|
||||||
if (!clipboard?.items.length) return;
|
if (!clipboard?.items.length) return;
|
||||||
|
|
||||||
|
const pastedElements =
|
||||||
editor.timeline.pasteAtTime({
|
editor.timeline.pasteAtTime({
|
||||||
time: editor.playback.getCurrentTime(),
|
time: editor.playback.getCurrentTime(),
|
||||||
clipboardItems: clipboard.items,
|
clipboardItems: clipboard.items,
|
||||||
});
|
}) ?? [];
|
||||||
|
setElementSelection({ elements: pastedElements });
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
|
||||||
type ElementRef = { trackId: string; elementId: string };
|
type ElementRef = { trackId: string; elementId: string };
|
||||||
|
|
||||||
export function useElementSelection() {
|
export function useElementSelection() {
|
||||||
const { selectedElements, setSelectedElements } = useTimelineStore();
|
const editor = useEditor();
|
||||||
|
const selectedElements = useSyncExternalStore(
|
||||||
|
(listener) => editor.selection.subscribe(listener),
|
||||||
|
() => editor.selection.getSelectedElements(),
|
||||||
|
);
|
||||||
|
|
||||||
const isElementSelected = useCallback(
|
const isElementSelected = useCallback(
|
||||||
({ trackId, elementId }: ElementRef) =>
|
({ trackId, elementId }: ElementRef) =>
|
||||||
@@ -17,9 +21,9 @@ export function useElementSelection() {
|
|||||||
|
|
||||||
const selectElement = useCallback(
|
const selectElement = useCallback(
|
||||||
({ trackId, elementId }: ElementRef) => {
|
({ trackId, elementId }: ElementRef) => {
|
||||||
setSelectedElements({ elements: [{ trackId, elementId }] });
|
editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] });
|
||||||
},
|
},
|
||||||
[setSelectedElements],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addElementToSelection = useCallback(
|
const addElementToSelection = useCallback(
|
||||||
@@ -30,23 +34,23 @@ export function useElementSelection() {
|
|||||||
);
|
);
|
||||||
if (alreadySelected) return;
|
if (alreadySelected) return;
|
||||||
|
|
||||||
setSelectedElements({
|
editor.selection.setSelectedElements({
|
||||||
elements: [...selectedElements, { trackId, elementId }],
|
elements: [...selectedElements, { trackId, elementId }],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[selectedElements, setSelectedElements],
|
[selectedElements, editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeElementFromSelection = useCallback(
|
const removeElementFromSelection = useCallback(
|
||||||
({ trackId, elementId }: ElementRef) => {
|
({ trackId, elementId }: ElementRef) => {
|
||||||
setSelectedElements({
|
editor.selection.setSelectedElements({
|
||||||
elements: selectedElements.filter(
|
elements: selectedElements.filter(
|
||||||
(element) =>
|
(element) =>
|
||||||
!(element.trackId === trackId && element.elementId === elementId),
|
!(element.trackId === trackId && element.elementId === elementId),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[selectedElements, setSelectedElements],
|
[selectedElements, editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleElementSelection = useCallback(
|
const toggleElementSelection = useCallback(
|
||||||
@@ -66,14 +70,14 @@ export function useElementSelection() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const clearElementSelection = useCallback(() => {
|
const clearElementSelection = useCallback(() => {
|
||||||
setSelectedElements({ elements: [] });
|
editor.selection.clearSelection();
|
||||||
}, [setSelectedElements]);
|
}, [editor]);
|
||||||
|
|
||||||
const setElementSelection = useCallback(
|
const setElementSelection = useCallback(
|
||||||
({ elements }: { elements: ElementRef[] }) => {
|
({ elements }: { elements: ElementRef[] }) => {
|
||||||
setSelectedElements({ elements });
|
editor.selection.setSelectedElements({ elements });
|
||||||
},
|
},
|
||||||
[setSelectedElements],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function useEditor(): EditorCore {
|
|||||||
editor.project.subscribe(handleStoreChange),
|
editor.project.subscribe(handleStoreChange),
|
||||||
editor.media.subscribe(handleStoreChange),
|
editor.media.subscribe(handleStoreChange),
|
||||||
editor.renderer.subscribe(handleStoreChange),
|
editor.renderer.subscribe(handleStoreChange),
|
||||||
|
editor.selection.subscribe(handleStoreChange),
|
||||||
];
|
];
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function useKeybindingsListener() {
|
|||||||
useKeybindingsStore();
|
useKeybindingsStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const eventOptions: AddEventListenerOptions = { capture: true };
|
||||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||||
// do not check keybinds if the mode is disabled
|
// do not check keybinds if the mode is disabled
|
||||||
if (!keybindingsEnabled) return;
|
if (!keybindingsEnabled) return;
|
||||||
@@ -53,10 +54,10 @@ export function useKeybindingsListener() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown, eventOptions);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown, eventOptions);
|
||||||
};
|
};
|
||||||
}, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
|
}, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,32 +70,32 @@ export const ACTIONS = {
|
|||||||
category: "navigation",
|
category: "navigation",
|
||||||
defaultShortcuts: ["end"],
|
defaultShortcuts: ["end"],
|
||||||
},
|
},
|
||||||
"split-selected": {
|
split: {
|
||||||
description: "Split element at playhead",
|
description: "Split elements at playhead",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
defaultShortcuts: ["s"],
|
defaultShortcuts: ["s"],
|
||||||
},
|
},
|
||||||
"split-selected-left": {
|
"split-left": {
|
||||||
description: "Split element at playhead and keep left",
|
description: "Split and remove left",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
defaultShortcuts: ["q"],
|
defaultShortcuts: ["q"],
|
||||||
},
|
},
|
||||||
"split-selected-right": {
|
"split-right": {
|
||||||
description: "Split element at playhead and keep right",
|
description: "Split and remove right",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
defaultShortcuts: ["w"],
|
defaultShortcuts: ["w"],
|
||||||
},
|
},
|
||||||
"delete-selected": {
|
"delete-selected": {
|
||||||
description: "Delete selected elements",
|
description: "Delete selected elements",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
defaultShortcuts: ["delete", "backspace"],
|
defaultShortcuts: ["backspace", "delete"],
|
||||||
},
|
},
|
||||||
"copy-selected": {
|
"copy-selected": {
|
||||||
description: "Copy selected elements",
|
description: "Copy selected elements",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
defaultShortcuts: ["ctrl+c"],
|
defaultShortcuts: ["ctrl+c"],
|
||||||
},
|
},
|
||||||
"paste-selected": {
|
"paste-copied": {
|
||||||
description: "Paste elements at playhead",
|
description: "Paste elements at playhead",
|
||||||
category: "editing",
|
category: "editing",
|
||||||
defaultShortcuts: ["ctrl+v"],
|
defaultShortcuts: ["ctrl+v"],
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
|
|
||||||
export class PasteCommand extends Command {
|
export class PasteCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: TimelineTrack[] | null = null;
|
||||||
private pastedElementIds: string[] = [];
|
private pastedElements: { trackId: string; elementId: string }[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private time: number,
|
private time: number,
|
||||||
@@ -28,7 +28,7 @@ export class PasteCommand extends Command {
|
|||||||
|
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.timeline.getTracks();
|
||||||
this.pastedElementIds = [];
|
this.pastedElements = [];
|
||||||
|
|
||||||
const minStart = Math.min(
|
const minStart = Math.min(
|
||||||
...this.clipboardItems.map((item) => item.element.startTime),
|
...this.clipboardItems.map((item) => item.element.startTime),
|
||||||
@@ -44,7 +44,6 @@ export class PasteCommand extends Command {
|
|||||||
items,
|
items,
|
||||||
minStart,
|
minStart,
|
||||||
time: this.time,
|
time: this.time,
|
||||||
pastedElementIds: this.pastedElementIds,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (elementsToAdd.length === 0) {
|
if (elementsToAdd.length === 0) {
|
||||||
@@ -68,6 +67,12 @@ export class PasteCommand extends Command {
|
|||||||
...targetTrack,
|
...targetTrack,
|
||||||
elements: [...targetTrack.elements, ...elementsToAdd],
|
elements: [...targetTrack.elements, ...elementsToAdd],
|
||||||
} as TimelineTrack;
|
} as TimelineTrack;
|
||||||
|
for (const element of elementsToAdd) {
|
||||||
|
this.pastedElements.push({
|
||||||
|
trackId: targetTrack.id,
|
||||||
|
elementId: element.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +86,12 @@ export class PasteCommand extends Command {
|
|||||||
elements: elementsToAdd,
|
elements: elementsToAdd,
|
||||||
});
|
});
|
||||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
updatedTracks.splice(insertIndex, 0, newTrack);
|
||||||
|
for (const element of elementsToAdd) {
|
||||||
|
this.pastedElements.push({
|
||||||
|
trackId: newTrack.id,
|
||||||
|
elementId: element.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
@@ -92,6 +103,10 @@ export class PasteCommand extends Command {
|
|||||||
editor.timeline.updateTracks(this.savedState);
|
editor.timeline.updateTracks(this.savedState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPastedElements(): { trackId: string; elementId: string }[] {
|
||||||
|
return this.pastedElements;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupClipboardItemsByTrackId({
|
function groupClipboardItemsByTrackId({
|
||||||
@@ -113,12 +128,10 @@ function buildPastedElements({
|
|||||||
items,
|
items,
|
||||||
minStart,
|
minStart,
|
||||||
time,
|
time,
|
||||||
pastedElementIds,
|
|
||||||
}: {
|
}: {
|
||||||
items: ClipboardItem[];
|
items: ClipboardItem[];
|
||||||
minStart: number;
|
minStart: number;
|
||||||
time: number;
|
time: number;
|
||||||
pastedElementIds: string[];
|
|
||||||
}): TimelineElement[] {
|
}): TimelineElement[] {
|
||||||
const elementsToAdd: TimelineElement[] = [];
|
const elementsToAdd: TimelineElement[] = [];
|
||||||
|
|
||||||
@@ -127,7 +140,6 @@ function buildPastedElements({
|
|||||||
const startTime = Math.max(0, time + relativeOffset);
|
const startTime = Math.max(0, time + relativeOffset);
|
||||||
const newElementId = generateUUID();
|
const newElementId = generateUUID();
|
||||||
|
|
||||||
pastedElementIds.push(newElementId);
|
|
||||||
elementsToAdd.push({
|
elementsToAdd.push({
|
||||||
...item.element,
|
...item.element,
|
||||||
id: newElementId,
|
id: newElementId,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface DuplicateElementsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DuplicateElementsCommand extends Command {
|
export class DuplicateElementsCommand extends Command {
|
||||||
private duplicatedIds: string[] = [];
|
private duplicatedElements: { trackId: string; elementId: string }[] = [];
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: TimelineTrack[] | null = null;
|
||||||
private elements: DuplicateElementsParams["elements"];
|
private elements: DuplicateElementsParams["elements"];
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export class DuplicateElementsCommand extends Command {
|
|||||||
execute(): void {
|
execute(): void {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.timeline.getTracks();
|
||||||
this.duplicatedIds = [];
|
this.duplicatedElements = [];
|
||||||
|
|
||||||
const updatedTracks = [...this.savedState];
|
const updatedTracks = [...this.savedState];
|
||||||
|
|
||||||
@@ -42,13 +42,22 @@ export class DuplicateElementsCommand extends Command {
|
|||||||
);
|
);
|
||||||
const newTrackElements: TimelineElement[] = [];
|
const newTrackElements: TimelineElement[] = [];
|
||||||
|
|
||||||
|
const newTrackId = generateUUID();
|
||||||
|
const newTrackBase = buildEmptyTrack({
|
||||||
|
id: newTrackId,
|
||||||
|
type: track.type,
|
||||||
|
});
|
||||||
|
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (!elementIdsToDuplicate.has(element.id)) {
|
if (!elementIdsToDuplicate.has(element.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newId = generateUUID();
|
const newId = generateUUID();
|
||||||
this.duplicatedIds.push(newId);
|
this.duplicatedElements.push({
|
||||||
|
trackId: newTrackId,
|
||||||
|
elementId: newId,
|
||||||
|
});
|
||||||
newTrackElements.push(
|
newTrackElements.push(
|
||||||
buildDuplicateElement({
|
buildDuplicateElement({
|
||||||
element,
|
element,
|
||||||
@@ -58,11 +67,6 @@ export class DuplicateElementsCommand extends Command {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTrackId = generateUUID();
|
|
||||||
const newTrackBase = buildEmptyTrack({
|
|
||||||
id: newTrackId,
|
|
||||||
type: track.type,
|
|
||||||
});
|
|
||||||
const newTrack = {
|
const newTrack = {
|
||||||
...newTrackBase,
|
...newTrackBase,
|
||||||
elements: newTrackElements,
|
elements: newTrackElements,
|
||||||
@@ -84,6 +88,10 @@ export class DuplicateElementsCommand extends Command {
|
|||||||
editor.timeline.updateTracks(this.savedState);
|
editor.timeline.updateTracks(this.savedState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDuplicatedElements(): { trackId: string; elementId: string }[] {
|
||||||
|
return this.duplicatedElements;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDuplicateElement({
|
function buildDuplicateElement({
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { EditorCore } from "@/core";
|
|||||||
|
|
||||||
export class SplitElementsCommand extends Command {
|
export class SplitElementsCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private savedState: TimelineTrack[] | null = null;
|
||||||
private newElementIds: string[] = [];
|
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
||||||
private affectedElementIds: Set<string> = new Set();
|
private previousSelection: { trackId: string; elementId: string }[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private elements: { trackId: string; elementId: string }[],
|
private elements: { trackId: string; elementId: string }[],
|
||||||
@@ -16,15 +16,15 @@ export class SplitElementsCommand extends Command {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
get splitElementIds(): string[] {
|
getRightSideElements(): { trackId: string; elementId: string }[] {
|
||||||
return Array.from(this.affectedElementIds);
|
return this.rightSideElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
execute(): void {
|
execute(): void {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.timeline.getTracks();
|
this.savedState = editor.timeline.getTracks();
|
||||||
this.newElementIds = [];
|
this.previousSelection = editor.selection.getSelectedElements();
|
||||||
this.affectedElementIds.clear();
|
this.rightSideElements = [];
|
||||||
|
|
||||||
const updatedTracks = this.savedState.map((track) => {
|
const updatedTracks = this.savedState.map((track) => {
|
||||||
const elementsToSplit = this.elements.filter(
|
const elementsToSplit = this.elements.filter(
|
||||||
@@ -61,7 +61,6 @@ export class SplitElementsCommand extends Command {
|
|||||||
const rightVisibleDuration = element.duration - relativeTime;
|
const rightVisibleDuration = element.duration - relativeTime;
|
||||||
|
|
||||||
if (this.retainSide === "left") {
|
if (this.retainSide === "left") {
|
||||||
this.affectedElementIds.add(element.id);
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...element,
|
...element,
|
||||||
@@ -74,7 +73,10 @@ export class SplitElementsCommand extends Command {
|
|||||||
|
|
||||||
if (this.retainSide === "right") {
|
if (this.retainSide === "right") {
|
||||||
const newId = generateUUID();
|
const newId = generateUUID();
|
||||||
this.affectedElementIds.add(newId);
|
this.rightSideElements.push({
|
||||||
|
trackId: track.id,
|
||||||
|
elementId: newId,
|
||||||
|
});
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...element,
|
...element,
|
||||||
@@ -89,9 +91,10 @@ export class SplitElementsCommand extends Command {
|
|||||||
|
|
||||||
// "both" - split into two pieces
|
// "both" - split into two pieces
|
||||||
const secondElementId = generateUUID();
|
const secondElementId = generateUUID();
|
||||||
this.affectedElementIds.add(element.id);
|
this.rightSideElements.push({
|
||||||
this.affectedElementIds.add(secondElementId);
|
trackId: track.id,
|
||||||
this.newElementIds.push(secondElementId);
|
elementId: secondElementId,
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -114,12 +117,17 @@ export class SplitElementsCommand extends Command {
|
|||||||
});
|
});
|
||||||
|
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
|
|
||||||
|
if (this.rightSideElements.length > 0) {
|
||||||
|
editor.selection.setSelectedElements({ elements: this.rightSideElements });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
if (this.savedState) {
|
if (this.savedState) {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
editor.timeline.updateTracks(this.savedState);
|
editor.timeline.updateTracks(this.savedState);
|
||||||
|
editor.selection.setSelectedElements({ elements: this.previousSelection });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CreateLibraryAudioElement,
|
CreateLibraryAudioElement,
|
||||||
TextElement,
|
TextElement,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
|
TimelineTrack,
|
||||||
AudioElement,
|
AudioElement,
|
||||||
VideoElement,
|
VideoElement,
|
||||||
ImageElement,
|
ImageElement,
|
||||||
@@ -225,3 +226,26 @@ export function buildLibraryAudioElement({
|
|||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getElementsAtTime({
|
||||||
|
tracks,
|
||||||
|
time,
|
||||||
|
}: {
|
||||||
|
tracks: TimelineTrack[];
|
||||||
|
time: number;
|
||||||
|
}): { trackId: string; elementId: string }[] {
|
||||||
|
const result: { trackId: string; elementId: string }[] = [];
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
for (const element of track.elements) {
|
||||||
|
const elementStart = element.startTime;
|
||||||
|
const elementEnd = element.startTime + element.duration;
|
||||||
|
|
||||||
|
if (time > elementStart && time < elementEnd) {
|
||||||
|
result.push({ trackId: track.id, elementId: element.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { getDefaultShortcuts } from "@/lib/actions";
|
|||||||
import { isTypableDOMElement } from "@/utils/browser";
|
import { isTypableDOMElement } from "@/utils/browser";
|
||||||
import { isAppleDevice } from "@/utils/platform";
|
import { isAppleDevice } from "@/utils/platform";
|
||||||
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
|
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
|
||||||
|
import {
|
||||||
|
runMigrations,
|
||||||
|
CURRENT_VERSION,
|
||||||
|
} from "./keybindings/migrations";
|
||||||
|
|
||||||
export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
||||||
|
|
||||||
@@ -35,8 +39,6 @@ interface KeybindingsState {
|
|||||||
action: TActionWithOptionalArgs,
|
action: TActionWithOptionalArgs,
|
||||||
) => KeybindingConflict | null;
|
) => KeybindingConflict | null;
|
||||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
|
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
|
||||||
|
|
||||||
// Utility
|
|
||||||
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +145,13 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "opencut-keybindings",
|
name: "opencut-keybindings",
|
||||||
version: 2,
|
version: CURRENT_VERSION,
|
||||||
|
partialize: (state) => ({
|
||||||
|
keybindings: state.keybindings,
|
||||||
|
isCustomized: state.isCustomized,
|
||||||
|
}),
|
||||||
|
migrate: (persisted, version) =>
|
||||||
|
runMigrations({ state: persisted, fromVersion: version }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { v2ToV3 } from './v2-to-v3';
|
||||||
|
|
||||||
|
type MigrationFn = ({ state }: { state: unknown }) => unknown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* key = version we're migrating from
|
||||||
|
* value = migration function
|
||||||
|
*/
|
||||||
|
const migrations: Record<number, MigrationFn> = {
|
||||||
|
2: v2ToV3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CURRENT_VERSION = 3;
|
||||||
|
|
||||||
|
export function runMigrations({
|
||||||
|
state,
|
||||||
|
fromVersion,
|
||||||
|
}: {
|
||||||
|
state: unknown;
|
||||||
|
fromVersion: number;
|
||||||
|
}): unknown {
|
||||||
|
let current = state;
|
||||||
|
for (let version = fromVersion; version < CURRENT_VERSION; version++) {
|
||||||
|
const migrate = migrations[version];
|
||||||
|
if (migrate) current = migrate({ state: current });
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
|
||||||
|
import type { TActionWithOptionalArgs } from "@/lib/actions";
|
||||||
|
|
||||||
|
interface V2State {
|
||||||
|
keybindings: KeybindingConfig;
|
||||||
|
isCustomized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function v2ToV3({ state }: { state: unknown }): unknown {
|
||||||
|
const v2 = state as V2State;
|
||||||
|
|
||||||
|
const renames: Record<string, string> = {
|
||||||
|
"split-selected": "split",
|
||||||
|
"split-selected-left": "split-left",
|
||||||
|
"split-selected-right": "split-right",
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrated = { ...v2.keybindings };
|
||||||
|
for (const [key, action] of Object.entries(migrated)) {
|
||||||
|
if (action && renames[action]) {
|
||||||
|
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...v2, keybindings: migrated };
|
||||||
|
}
|
||||||
@@ -7,12 +7,6 @@ import { create } from "zustand";
|
|||||||
import type { ClipboardItem } from "@/types/timeline";
|
import type { ClipboardItem } from "@/types/timeline";
|
||||||
|
|
||||||
interface TimelineStore {
|
interface TimelineStore {
|
||||||
selectedElements: { trackId: string; elementId: string }[];
|
|
||||||
setSelectedElements: ({
|
|
||||||
elements,
|
|
||||||
}: {
|
|
||||||
elements: { trackId: string; elementId: string }[];
|
|
||||||
}) => void;
|
|
||||||
snappingEnabled: boolean;
|
snappingEnabled: boolean;
|
||||||
toggleSnapping: () => void;
|
toggleSnapping: () => void;
|
||||||
rippleEditingEnabled: boolean;
|
rippleEditingEnabled: boolean;
|
||||||
@@ -28,12 +22,6 @@ interface TimelineStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useTimelineStore = create<TimelineStore>((set) => ({
|
export const useTimelineStore = create<TimelineStore>((set) => ({
|
||||||
selectedElements: [],
|
|
||||||
|
|
||||||
setSelectedElements: ({ elements }) => {
|
|
||||||
set({ selectedElements: elements });
|
|
||||||
},
|
|
||||||
|
|
||||||
snappingEnabled: true,
|
snappingEnabled: true,
|
||||||
|
|
||||||
toggleSnapping: () => {
|
toggleSnapping: () => {
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Context Menu Designs</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
|
||||||
|
body { font-family: 'Inter', sans-serif; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--brand-blue: #00A3FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility to hide/show */
|
||||||
|
.context-menu { display: none; }
|
||||||
|
.context-menu.open { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#ffffff] text-slate-900 min-h-screen p-12">
|
||||||
|
|
||||||
|
<div class="max-w-6xl mx-auto space-y-4 mb-12 text-center">
|
||||||
|
<h1 class="text-2xl font-bold">Context Menu Explorations</h1>
|
||||||
|
<p class="text-slate-500">Right-click simulations (right-click buttons to toggle).</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-4 mt-8">
|
||||||
|
<button oncontextmenu="event.preventDefault(); toggle(1)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
|
||||||
|
Option 1: Standard
|
||||||
|
</button>
|
||||||
|
<button oncontextmenu="event.preventDefault(); toggle(2)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
|
||||||
|
Option 2: Rich with Shortcuts
|
||||||
|
</button>
|
||||||
|
<button oncontextmenu="event.preventDefault(); toggle(3)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
|
||||||
|
Option 3: Dense & Technical
|
||||||
|
</button>
|
||||||
|
<button oncontextmenu="event.preventDefault(); toggle(4)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
|
||||||
|
Option 4: Dense & Technical (Icons)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-start max-w-7xl mx-auto relative h-[500px]">
|
||||||
|
|
||||||
|
<!-- Option 1: Standard -->
|
||||||
|
<!-- Characteristics: Simple, clean, standard spacing -->
|
||||||
|
<div id="menu-1" class="context-menu absolute top-0 left-[15%] -translate-x-1/2 w-64 bg-white rounded-[20px] shadow-xl border border-slate-200 overflow-hidden py-2 z-10">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
|
||||||
|
<i data-lucide="folder-open" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
|
||||||
|
<span>Open</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
|
||||||
|
<i data-lucide="edit-2" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
|
||||||
|
<span>Rename</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
|
||||||
|
<i data-lucide="copy" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
|
||||||
|
<span>Duplicate</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="h-px bg-slate-100 my-1 mx-2"></div>
|
||||||
|
|
||||||
|
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
|
||||||
|
<i data-lucide="share" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
|
||||||
|
<span>Share...</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
|
||||||
|
<i data-lucide="download" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
|
||||||
|
<span>Download</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="h-px bg-slate-100 my-1 mx-2"></div>
|
||||||
|
|
||||||
|
<a href="#" class="block px-4 py-2 mx-2 text-sm text-red-500 rounded-xl hover:bg-red-50 flex items-center gap-3 group">
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4 group-hover:text-red-600"></i>
|
||||||
|
<span>Delete</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Option 2: Rich with Shortcuts -->
|
||||||
|
<!-- Characteristics: Wider, right-aligned shortcuts, grouping -->
|
||||||
|
<div id="menu-2" class="context-menu absolute top-0 left-[38%] -translate-x-1/2 w-72 bg-white rounded-[20px] shadow-xl border border-slate-200 overflow-hidden py-2 z-10">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<div class="px-4 py-1.5 text-[11px] font-bold uppercase tracking-wider text-slate-400 select-none">
|
||||||
|
Actions
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i data-lucide="scissors" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
|
||||||
|
<span>Cut</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘X</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i data-lucide="copy" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
|
||||||
|
<span>Copy</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘C</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i data-lucide="clipboard" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
|
||||||
|
<span>Paste</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘V</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="h-px bg-slate-100 my-1 mx-2"></div>
|
||||||
|
|
||||||
|
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i data-lucide="maximize" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
|
||||||
|
<span>Properties</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘I</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Option 3: Dense & Technical -->
|
||||||
|
<!-- Characteristics: Smaller text, smaller padding, minimal radius, efficient -->
|
||||||
|
<div id="menu-3" class="context-menu absolute top-0 left-[62%] -translate-x-1/2 w-60 bg-white rounded-xl shadow-xl border border-slate-200 overflow-hidden py-2.5 z-10">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
|
||||||
|
<span>New Folder</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
|
||||||
|
<span>New File</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="h-px bg-slate-100 my-2"></div>
|
||||||
|
|
||||||
|
<div class="relative group/submenu">
|
||||||
|
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
|
||||||
|
<span>Sort By</span>
|
||||||
|
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative group/submenu">
|
||||||
|
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
|
||||||
|
<span>View As</span>
|
||||||
|
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-px bg-slate-100 my-2"></div>
|
||||||
|
|
||||||
|
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
|
||||||
|
<span>Refresh</span>
|
||||||
|
<span class="text-xs text-slate-400">R</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option 4: Dense & Technical (Icons) -->
|
||||||
|
<div id="menu-4" class="context-menu absolute top-0 left-[85%] -translate-x-1/2 w-60 bg-white rounded-xl shadow-xl border border-slate-200 overflow-hidden py-2.5 z-10">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<i data-lucide="folder-plus" class="w-4 h-4 text-slate-400 group-hover:text-slate-600"></i>
|
||||||
|
<span>New Folder</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<i data-lucide="file-plus" class="w-4 h-4 text-slate-400 group-hover:text-slate-600"></i>
|
||||||
|
<span>New File</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="h-px bg-slate-100 my-2"></div>
|
||||||
|
|
||||||
|
<div class="relative group/submenu">
|
||||||
|
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<i data-lucide="arrow-up-down" class="w-4 h-4 text-slate-400"></i>
|
||||||
|
<span>Sort By</span>
|
||||||
|
</div>
|
||||||
|
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative group/submenu">
|
||||||
|
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<i data-lucide="layout-grid" class="w-4 h-4 text-slate-400"></i>
|
||||||
|
<span>View As</span>
|
||||||
|
</div>
|
||||||
|
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-px bg-slate-100 my-2"></div>
|
||||||
|
|
||||||
|
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<i data-lucide="rotate-cw" class="w-4 h-4 text-slate-400 group-hover:text-slate-600"></i>
|
||||||
|
<span>Refresh</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-slate-400">R</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
function toggle(id) {
|
||||||
|
// Close others
|
||||||
|
[1, 2, 3, 4].forEach(num => {
|
||||||
|
if (num !== id) {
|
||||||
|
document.getElementById(`menu-${num}`).classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Toggle current
|
||||||
|
const menu = document.getElementById(`menu-${id}`);
|
||||||
|
if (menu) {
|
||||||
|
menu.classList.toggle('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('button') && !e.target.closest('.context-menu')) {
|
||||||
|
document.querySelectorAll('.context-menu').forEach(el => el.classList.remove('open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open option 2 by default
|
||||||
|
toggle(2);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+113
-97
@@ -25,18 +25,15 @@
|
|||||||
<body class="bg-[#ffffff] text-slate-900 min-h-screen p-12">
|
<body class="bg-[#ffffff] text-slate-900 min-h-screen p-12">
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto space-y-4 mb-12 text-center">
|
<div class="max-w-4xl mx-auto space-y-4 mb-12 text-center">
|
||||||
<h1 class="text-2xl font-bold">Dialog Design Explorations</h1>
|
<h1 class="text-2xl font-bold">Project Info Explorations</h1>
|
||||||
<p class="text-slate-500">Three interactions for the same brand language. Click buttons to open.</p>
|
<p class="text-slate-500">Two layout approaches using the same "Structured" brand style.</p>
|
||||||
|
|
||||||
<div class="flex items-center justify-center gap-4 mt-8">
|
<div class="flex items-center justify-center gap-4 mt-8">
|
||||||
<button onclick="openDialog(1)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm">
|
<button onclick="openDialog(1)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
|
||||||
Option 1: Soft & Friendly
|
Layout 1: Cards & Grid
|
||||||
</button>
|
</button>
|
||||||
<button onclick="openDialog(2)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm">
|
<button onclick="openDialog(2)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
|
||||||
Option 2: Structured
|
Layout 2: Clean Rows
|
||||||
</button>
|
|
||||||
<button onclick="openDialog(3)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm">
|
|
||||||
Option 3: Edge Accent
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,125 +41,145 @@
|
|||||||
<!-- Overlay (Shared) -->
|
<!-- Overlay (Shared) -->
|
||||||
<div id="overlay" class="dialog-overlay fixed inset-0 bg-slate-900/20 backdrop-blur-sm z-50 items-center justify-center p-4">
|
<div id="overlay" class="dialog-overlay fixed inset-0 bg-slate-900/20 backdrop-blur-sm z-50 items-center justify-center p-4">
|
||||||
|
|
||||||
<!-- Option 1: Soft & Friendly -->
|
<!-- Option 1: Cards & Grid -->
|
||||||
<!-- Characteristics: Very rounded, generous padding, minimal dividers -->
|
<!-- Focus: High visibility, dashboard feel, grouping info into boxes -->
|
||||||
<div id="dialog-1" class="hidden w-full max-w-md bg-white rounded-[28px] shadow-xl p-8 relative">
|
<div id="dialog-1" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
|
||||||
<button onclick="closeAll()" class="absolute top-6 right-6 p-2 text-slate-400 hover:text-slate-600 rounded-full hover:bg-slate-50">
|
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="mb-6">
|
|
||||||
<div class="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center mb-4 text-[#00A3FF]">
|
|
||||||
<i data-lucide="share-2" class="w-6 h-6"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-xl font-bold text-slate-900 mb-2">Share Project</h2>
|
|
||||||
<p class="text-slate-500 text-[15px] leading-relaxed">
|
|
||||||
Invite collaborators to <strong>Summer Vlog 2025</strong>. They will receive an email invitation to edit.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 mb-8">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Email address</label>
|
|
||||||
<input type="email" placeholder="colleague@example.com" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-[#00A3FF] focus:bg-white focus:ring-4 focus:ring-blue-500/10 placeholder:text-slate-400">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-3 border border-slate-100 rounded-xl">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600 text-xs font-bold">JM</div>
|
|
||||||
<span class="text-sm font-medium text-slate-700">John Miller</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs font-medium text-slate-400 px-2 py-1 bg-slate-50 rounded-md">Owner</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button onclick="closeAll()" class="flex-1 px-5 py-3 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-xl">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button class="flex-1 px-5 py-3 text-sm font-bold text-white bg-[#00A3FF] hover:bg-[#008BE0] rounded-xl shadow-lg shadow-blue-500/20">
|
|
||||||
Send Invite
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Option 2: Structured -->
|
|
||||||
<!-- Characteristics: Clear header/footer separation, solid pill buttons, highly organized -->
|
|
||||||
<div id="dialog-2" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-white">
|
<div class="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-white">
|
||||||
<h2 class="text-lg font-bold text-slate-900">Export Video</h2>
|
<h2 class="text-lg font-bold text-slate-900">Project Info</h2>
|
||||||
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600">
|
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 transition-colors">
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
<i data-lucide="x" class="w-5 h-5"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<!-- Name Input -->
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label class="text-xs font-semibold text-slate-500">Resolution</label>
|
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider">Project Name</label>
|
||||||
<button class="w-full flex items-center justify-between px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm font-medium text-slate-700 hover:border-[#00A3FF]">
|
<div class="relative">
|
||||||
<span>1080p HD</span>
|
<input type="text" value="Summer Vlog 2025" class="w-full bg-white border border-slate-200 rounded-xl px-4 py-2.5 text-sm font-medium text-slate-900 focus:outline-none focus:border-[#00A3FF] focus:ring-4 focus:ring-blue-500/10 shadow-sm transition-shadow">
|
||||||
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400"></i>
|
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
|
||||||
</button>
|
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label class="text-xs font-semibold text-slate-500">Format</label>
|
|
||||||
<button class="w-full flex items-center justify-between px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm font-medium text-slate-700 hover:border-[#00A3FF]">
|
|
||||||
<span>MP4 (H.264)</span>
|
|
||||||
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 bg-slate-50 rounded-xl border border-slate-100 flex items-start gap-3">
|
<!-- Stats Grid -->
|
||||||
<i data-lucide="info" class="w-5 h-5 text-[#00A3FF] mt-0.5 flex-shrink-0"></i>
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<div class="text-sm text-slate-600">
|
<div class="p-3 bg-slate-50 border border-slate-100 rounded-xl text-center">
|
||||||
<p class="font-medium text-slate-900 mb-1">Estimated file size: 245 MB</p>
|
<p class="text-[10px] uppercase tracking-wide font-bold text-slate-400 mb-1">Duration</p>
|
||||||
<p>Exporting may take a few minutes depending on your hardware.</p>
|
<p class="text-sm font-semibold text-slate-900">03:45</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-slate-50 border border-slate-100 rounded-xl text-center">
|
||||||
|
<p class="text-[10px] uppercase tracking-wide font-bold text-slate-400 mb-1">Format</p>
|
||||||
|
<p class="text-sm font-semibold text-slate-900">1080p</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-slate-50 border border-slate-100 rounded-xl text-center">
|
||||||
|
<p class="text-[10px] uppercase tracking-wide font-bold text-slate-400 mb-1">FPS</p>
|
||||||
|
<p class="text-sm font-semibold text-slate-900">60</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage Info Card -->
|
||||||
|
<div class="p-4 bg-blue-50/50 rounded-xl border border-blue-100 flex gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-blue-100 text-[#00A3FF] flex items-center justify-center flex-shrink-0">
|
||||||
|
<i data-lucide="hard-drive" class="w-5 h-5"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex justify-between items-center mb-1.5">
|
||||||
|
<span class="text-xs font-bold text-slate-700">Storage</span>
|
||||||
|
<span class="text-xs font-medium text-slate-500">1.2 GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full bg-blue-100 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-[#00A3FF] w-[35%] rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-slate-400 mt-2">Last modified Jan 22, 2026</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-3">
|
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-3">
|
||||||
<button onclick="closeAll()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 bg-white border border-slate-200 rounded-lg hover:bg-slate-50">
|
<button onclick="closeAll()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors">
|
||||||
Cancel
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button class="px-4 py-2 text-sm font-bold text-white bg-[#00A3FF] hover:bg-[#008BE0] rounded-lg shadow-md shadow-blue-500/10">
|
<button class="px-4 py-2 text-sm font-bold text-white bg-[#00A3FF] hover:bg-[#008BE0] rounded-lg shadow-md shadow-blue-500/10 transition-colors">
|
||||||
Export Video
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Option 3: Edge Accent -->
|
<!-- Option 2: Clean Rows -->
|
||||||
<!-- Characteristics: Minimalist, blue left border accent, very clean interactions -->
|
<!-- Focus: Scannability, list view, technical details, minimal containers -->
|
||||||
<div id="dialog-3" class="hidden w-full max-w-md bg-white rounded-2xl shadow-xl overflow-hidden relative">
|
<div id="dialog-2" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
|
||||||
<!-- Accent Bar -->
|
<!-- Header -->
|
||||||
<div class="absolute left-0 top-0 bottom-0 w-1.5 bg-[#00A3FF]"></div>
|
<div class="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-white">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<div class="pl-8 pr-6 py-6">
|
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-slate-100 to-slate-200 border border-slate-200"></div>
|
||||||
<div class="flex items-start justify-between mb-2">
|
<h2 class="text-lg font-bold text-slate-900">Project Details</h2>
|
||||||
<h2 class="text-lg font-bold text-slate-900">Delete Project?</h2>
|
</div>
|
||||||
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 -mt-1 -mr-2 p-2">
|
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 transition-colors">
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
<i data-lucide="x" class="w-5 h-5"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-slate-500 text-sm mb-6">
|
<!-- Body -->
|
||||||
This will permanently delete <span class="text-slate-900 font-medium">"Summer Vlog 2025"</span> and all its associated media files. This action cannot be undone.
|
<div class="px-6 py-2">
|
||||||
</p>
|
<!-- Row 1: Name -->
|
||||||
|
<div class="py-4 flex items-center justify-between border-b border-slate-50">
|
||||||
|
<div class="flex items-center gap-3 text-slate-500">
|
||||||
|
<i data-lucide="type" class="w-4 h-4"></i>
|
||||||
|
<span class="text-sm font-medium">Name</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" value="Summer Vlog 2025" class="text-right text-sm font-semibold text-slate-900 bg-transparent border-none focus:ring-0 w-48 p-0 placeholder-slate-300">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 pt-2">
|
<!-- Row 2: Created -->
|
||||||
<button class="flex-1 px-4 py-2.5 text-sm font-bold text-white bg-red-500 hover:bg-red-600 rounded-lg shadow-sm">
|
<div class="py-4 flex items-center justify-between border-b border-slate-50">
|
||||||
Yes, delete it
|
<div class="flex items-center gap-3 text-slate-500">
|
||||||
|
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||||
|
<span class="text-sm font-medium">Created</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-slate-900">Jan 22, 2026</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Technical -->
|
||||||
|
<div class="py-4 flex items-center justify-between border-b border-slate-50">
|
||||||
|
<div class="flex items-center gap-3 text-slate-500">
|
||||||
|
<i data-lucide="monitor" class="w-4 h-4"></i>
|
||||||
|
<span class="text-sm font-medium">Resolution</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="px-2 py-1 bg-slate-100 rounded-md text-xs font-bold text-slate-600">1080p</span>
|
||||||
|
<span class="px-2 py-1 bg-slate-100 rounded-md text-xs font-bold text-slate-600">60fps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 4: Size -->
|
||||||
|
<div class="py-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3 text-slate-500">
|
||||||
|
<i data-lucide="hard-drive" class="w-4 h-4"></i>
|
||||||
|
<span class="text-sm font-medium">Size</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-slate-900">1.2 GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
|
||||||
|
<button class="text-xs font-medium text-red-500 hover:text-red-600 transition-colors flex items-center gap-1.5">
|
||||||
|
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
|
||||||
|
Delete Project
|
||||||
</button>
|
</button>
|
||||||
<button onclick="closeAll()" class="px-4 py-2.5 text-sm font-medium text-slate-600 hover:text-slate-900 border border-slate-200 rounded-lg hover:bg-slate-50">
|
<div class="flex gap-3">
|
||||||
|
<button onclick="closeAll()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
<button class="px-4 py-2 text-sm font-bold text-white bg-[#00A3FF] hover:bg-[#008BE0] rounded-lg shadow-md shadow-blue-500/10 transition-colors">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,8 +192,7 @@
|
|||||||
const overlay = document.getElementById('overlay');
|
const overlay = document.getElementById('overlay');
|
||||||
const dialogs = [
|
const dialogs = [
|
||||||
document.getElementById('dialog-1'),
|
document.getElementById('dialog-1'),
|
||||||
document.getElementById('dialog-2'),
|
document.getElementById('dialog-2')
|
||||||
document.getElementById('dialog-3')
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function openDialog(id) {
|
function openDialog(id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user