From 1f8391487e7a01232cc745e6205bdaeccee4bd1c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 27 Jan 2026 16:39:59 +0100 Subject: [PATCH] we're basically done --- .cursor/debug.log | 11 + .cursor/rules/codebase-index.mdc | 10 +- apps/web/src/app/blog/page.tsx | 2 +- apps/web/src/app/contributors/page.tsx | 10 +- apps/web/src/app/globals.css | 18 +- apps/web/src/app/projects/page.tsx | 74 ++++-- apps/web/src/app/projects/store.ts | 42 ++- .../editor/assets-panel/drag-overlay.tsx | 4 +- .../components/editor/assets-panel/tabbar.tsx | 2 +- .../editor/assets-panel/views/media.tsx | 248 +++++++++--------- .../editor/assets-panel/views/sounds.tsx | 2 +- .../editor/delete-project-dialog.tsx | 41 ++- .../src/components/editor/draggable-item.tsx | 2 +- .../components/editor/editable-timecode.tsx | 2 +- .../src/components/editor/export-button.tsx | 10 +- .../components/editor/project-info-dialog.tsx | 17 +- .../editor/properties-panel/property-item.tsx | 4 +- .../properties-panel/text-properties.tsx | 5 +- .../components/editor/shortcuts-dialog.tsx | 24 +- .../editor/timeline/audio-waveform.tsx | 2 +- .../src/components/editor/timeline/index.tsx | 20 +- .../editor/timeline/timeline-element.tsx | 223 +++++++--------- .../editor/timeline/timeline-toolbar.tsx | 16 +- apps/web/src/components/footer.tsx | 2 +- apps/web/src/components/header.tsx | 2 +- apps/web/src/components/landing/hero.tsx | 3 +- .../src/components/rename-project-dialog.tsx | 33 +-- apps/web/src/components/ui/accordion.tsx | 4 +- apps/web/src/components/ui/alert.tsx | 19 +- apps/web/src/components/ui/badge.tsx | 2 +- apps/web/src/components/ui/breadcrumb.tsx | 2 +- apps/web/src/components/ui/button.tsx | 22 +- apps/web/src/components/ui/checkbox.tsx | 2 +- apps/web/src/components/ui/color-picker.tsx | 2 +- apps/web/src/components/ui/context-menu.tsx | 63 +++-- apps/web/src/components/ui/dialog.tsx | 23 +- apps/web/src/components/ui/dropdown-menu.tsx | 4 +- apps/web/src/components/ui/input-otp.tsx | 2 +- .../web/src/components/ui/input-with-back.tsx | 2 +- apps/web/src/components/ui/input.tsx | 9 +- apps/web/src/components/ui/label.tsx | 2 +- .../web/src/components/ui/navigation-menu.tsx | 2 +- apps/web/src/components/ui/progress.tsx | 2 +- apps/web/src/components/ui/slider.tsx | 2 +- apps/web/src/components/ui/split-button.tsx | 4 +- apps/web/src/components/ui/switch.tsx | 4 +- apps/web/src/components/ui/table.tsx | 2 +- apps/web/src/components/ui/toast.tsx | 6 +- apps/web/src/components/ui/toggle.tsx | 2 +- apps/web/src/constants/site-constants.ts | 6 - apps/web/src/core/index.ts | 3 + .../src/core/managers/selection-manager.ts | 35 +++ .../web/src/core/managers/timeline-manager.ts | 10 +- .../src/hooks/actions/use-editor-actions.ts | 87 ++++-- .../timeline/element/use-element-selection.ts | 30 ++- apps/web/src/hooks/use-editor.ts | 1 + apps/web/src/hooks/use-keybindings.ts | 37 +-- apps/web/src/lib/actions/definitions.ts | 16 +- .../lib/commands/timeline/clipboard/paste.ts | 24 +- .../timeline/element/duplicate-elements.ts | 24 +- .../timeline/element/split-elements.ts | 30 ++- apps/web/src/lib/timeline/element-utils.ts | 24 ++ apps/web/src/stores/keybindings-store.ts | 14 +- .../stores/keybindings/migrations/index.ts | 28 ++ .../stores/keybindings/migrations/v2-to-v3.ts | 26 ++ apps/web/src/stores/timeline-store.ts | 12 - apps/web/src/utils/{strings.ts => string.ts} | 0 html/context_menu_designs.html | 243 +++++++++++++++++ html/dialog_designs.html | 222 ++++++++-------- packages/env/src/web.ts | 46 ++-- 70 files changed, 1230 insertions(+), 699 deletions(-) create mode 100644 .cursor/debug.log create mode 100644 apps/web/src/core/managers/selection-manager.ts create mode 100644 apps/web/src/stores/keybindings/migrations/index.ts create mode 100644 apps/web/src/stores/keybindings/migrations/v2-to-v3.ts rename apps/web/src/utils/{strings.ts => string.ts} (100%) create mode 100644 html/context_menu_designs.html diff --git a/.cursor/debug.log b/.cursor/debug.log new file mode 100644 index 00000000..af2bcd54 --- /dev/null +++ b/.cursor/debug.log @@ -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} diff --git a/.cursor/rules/codebase-index.mdc b/.cursor/rules/codebase-index.mdc index ce51e890..6ef001f5 100644 --- a/.cursor/rules/codebase-index.mdc +++ b/.cursor/rules/codebase-index.mdc @@ -113,6 +113,7 @@ index.ts renderer: RendererManager save: SaveManager audio: AudioManager + selection: SelectionManager static getInstance(): EditorCore static reset(): void } @@ -925,6 +926,13 @@ element-utils.ts startTime: number; buffer?: AudioBuffer; }): CreateLibraryAudioElement + export function getElementsAtTime({ + tracks, + time, + }: { + tracks: TimelineTrack[]; + time: number; + }): { trackId: string; elementId: string }[] index.ts export function calculateTotalDuration({ @@ -1692,7 +1700,7 @@ platform.ts export function getPlatformAlternateKey(): string export function isAppleDevice(): boolean -strings.ts +string.ts export function capitalizeFirstLetter({ string }: { string: string }) export function uppercase({ string }: { string: string }) diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx index 6f9c57bc..9e3b6d11 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -42,7 +42,7 @@ export default async function BlogPage() { function BlogPostItem({ post }: { post: Post }) { return ( -
+

{post.title}

{post.description}

diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index 2e8c49cd..21703852 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -180,7 +180,7 @@ function AllContributorsSection({ href={contributor.html_url} target="_blank" rel="noopener noreferrer" - className="opacity-100 transition-opacity hover:opacity-70" + className="opacity-100 hover:opacity-70" >
@@ -214,7 +214,7 @@ function ExternalToolsSection() {

Tools we use to build OpenCut

-
+
{EXTERNAL_TOOLS.map((tool, index) => ( - -
- -
+ +

{tool.name}

diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index da2523fc..8478dabe 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -15,6 +15,7 @@ --popover: hsl(0, 0%, 100%); --popover-foreground: hsl(0 0% 2%); --primary: hsl(203, 100%, 50%); + --primary-hover: hsl(203, 100%, 45%); --primary-foreground: hsl(0, 0%, 100%); --secondary: hsl(216, 13%, 92%); --secondary-foreground: hsl(0 0% 2%); @@ -44,9 +45,6 @@ --sidebar-ring: hsl(0 0% 16.9%); --panel-background: hsl(216 13% 96%); --panel-accent: hsl(216, 8%, 90%); - - --radius: 1rem; - --sidebar: hsl(0 0% 98%); } .dark { @@ -56,7 +54,8 @@ --card-foreground: hsl(0 0% 89%); --popover: hsl(0 0% 14.9%); --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%); --secondary: hsl(0 0% 14.9%); --secondary-foreground: hsl(0 0% 98%); @@ -124,15 +123,16 @@ --font-sans: var(--font-inter), sans-serif; /* Font sizes */ - --text-base: 0.95rem; + --text-xl: 1.20rem; + --text-base: 0.92rem; --text-base--line-height: calc(1.5 / 0.95); --text-xs: 0.75rem; --text-xs--line-height: calc(1 / 0.8); /* Border radius */ - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 8px); + --radius-lg: 0.82rem; + --radius-md: 0.65rem; + --radius-sm: 0.35rem; /* Palette mapped to root design tokens */ --color-background: var(--background); @@ -146,7 +146,7 @@ --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); - + --color-primary-hover: var(--primary-hover); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 59868704..4c7229a7 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -110,7 +110,11 @@ export default function ProjectsPage() { } > {projectsToDisplay.map((project) => ( - + p.id)} + /> ))}

)} @@ -144,10 +148,10 @@ function ProjectsHeader() { -
+
); } -function ProjectItem({ project }: { project: TProjectMetadata }) { - const { selectedProjectIds, viewMode, setProjectSelected } = +function ProjectItem({ + project, + allProjectIds, +}: { + project: TProjectMetadata; + allProjectIds: string[]; +}) { + const { selectedProjectIds, viewMode, setProjectSelected, selectProjectRange } = useProjectsStore(); const selectedProjectIdSet = new Set(selectedProjectIds); const isSelected = selectedProjectIdSet.has(project.id); @@ -541,6 +549,20 @@ function ProjectItem({ project }: { project: TProjectMetadata }) { const isMultiSelect = selectedProjectCount > 1; 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 = (
@@ -567,7 +589,7 @@ function ProjectItem({ project }: { project: TProjectMetadata }) {
-

+

{project.name}

@@ -595,7 +617,7 @@ function ProjectItem({ project }: { project: TProjectMetadata }) { )}
-

+

{project.name}

@@ -617,12 +639,14 @@ function ProjectItem({ project }: { project: TProjectMetadata }) { > - setProjectSelected({ - projectId: project.id, - isSelected: checked === true, - }) - } + onMouseDown={(event) => event.preventDefault()} + onClick={(event) => { + handleCheckboxChange({ + checked: !isSelected, + shiftKey: event.shiftKey, + }); + }} + onCheckedChange={() => {}} className="size-5 shrink-0" /> @@ -657,12 +681,14 @@ function ProjectItem({ project }: { project: TProjectMetadata }) { <> - setProjectSelected({ - projectId: project.id, - isSelected: checked === true, - }) - } + onMouseDown={(event) => event.preventDefault()} + onClick={(event) => { + handleCheckboxChange({ + checked: !isSelected, + shiftKey: event.shiftKey, + }); + }} + onCheckedChange={() => {}} className={`absolute z-10 size-5 top-3 left-3 ${ isSelected || isDropdownOpen ? "opacity-100" diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts index 0eb149a1..eaf8f947 100644 --- a/apps/web/src/app/projects/store.ts +++ b/apps/web/src/app/projects/store.ts @@ -10,6 +10,7 @@ interface ProjectsState { sortOrder: "asc" | "desc"; viewMode: ProjectsViewMode; selectedProjectIds: string[]; + lastSelectedProjectId: string | null; isHydrated: boolean; setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void; setSearchQuery: ({ query }: { query: string }) => void; @@ -26,6 +27,13 @@ interface ProjectsState { projectId: string; isSelected: boolean; }) => void; + selectProjectRange: ({ + projectId, + allProjectIds, + }: { + projectId: string; + allProjectIds: string[]; + }) => void; } const getNextSelectedProjectIds = ({ @@ -56,6 +64,7 @@ export const useProjectsStore = create()( sortOrder: "desc", viewMode: "grid", selectedProjectIds: [], + lastSelectedProjectId: null, isHydrated: false, setIsHydrated: ({ isHydrated }) => set({ isHydrated }), setSearchQuery: ({ query }) => set({ searchQuery: query }), @@ -68,7 +77,8 @@ export const useProjectsStore = create()( setViewMode: ({ viewMode }) => set({ viewMode }), setSelectedProjects: ({ projectIds }) => set({ selectedProjectIds: projectIds }), - clearSelectedProjects: () => set({ selectedProjectIds: [] }), + clearSelectedProjects: () => + set({ selectedProjectIds: [], lastSelectedProjectId: null }), setProjectSelected: ({ projectId, isSelected }) => set((state) => ({ selectedProjectIds: getNextSelectedProjectIds({ @@ -76,7 +86,37 @@ export const useProjectsStore = create()( projectId, 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", diff --git a/apps/web/src/components/editor/assets-panel/drag-overlay.tsx b/apps/web/src/components/editor/assets-panel/drag-overlay.tsx index 3f69d1ed..c6b0861c 100644 --- a/apps/web/src/components/editor/assets-panel/drag-overlay.tsx +++ b/apps/web/src/components/editor/assets-panel/drag-overlay.tsx @@ -29,7 +29,7 @@ export function MediaDragOverlay({ return ( -
- +
+ Assets +
+ + + + + + +

+ {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} +

+
- - - + + + + + + + + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + +

- {mediaViewMode === "grid" - ? "Switch to list view" - : "Switch to grid view"} + Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"})

- - - - - - - - - { - if (sortBy === key) { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc", - ); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - { - if (sortBy === key) { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc", - ); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - { - if (sortBy === key) { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc", - ); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - { - if (sortBy === key) { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc", - ); - } else { - setSortBy(key); - setSortOrder("asc"); - } - }} - /> - - - -

- Sort by {sortBy} ( - {sortOrder === "asc" ? "ascending" : "descending"}) -

-
-
-
-
+ + +
diff --git a/apps/web/src/components/editor/assets-panel/views/sounds.tsx b/apps/web/src/components/editor/assets-panel/views/sounds.tsx index c6d9a335..84a5d0bf 100644 --- a/apps/web/src/components/editor/assets-panel/views/sounds.tsx +++ b/apps/web/src/components/editor/assets-panel/views/sounds.tsx @@ -519,7 +519,7 @@ function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) { }; return ( -
+
diff --git a/apps/web/src/components/editor/draggable-item.tsx b/apps/web/src/components/editor/draggable-item.tsx index f1196281..10cb1f8c 100644 --- a/apps/web/src/components/editor/draggable-item.tsx +++ b/apps/web/src/components/editor/draggable-item.tsx @@ -163,7 +163,7 @@ export function DraggableItem({
{preview}
- {name} + {name}
)} diff --git a/apps/web/src/components/editor/editable-timecode.tsx b/apps/web/src/components/editor/editable-timecode.tsx index d56d1771..d32f067a 100644 --- a/apps/web/src/components/editor/editable-timecode.tsx +++ b/apps/web/src/components/editor/editable-timecode.tsx @@ -142,7 +142,7 @@ export function EditableTimecode({ disabled={disabled} className={cn( "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", className, )} diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index 52f62332..96fbda6f 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -39,9 +39,9 @@ export function ExportButton() { diff --git a/apps/web/src/components/editor/project-info-dialog.tsx b/apps/web/src/components/editor/project-info-dialog.tsx index 5b944a76..4797d089 100644 --- a/apps/web/src/components/editor/project-info-dialog.tsx +++ b/apps/web/src/components/editor/project-info-dialog.tsx @@ -1,12 +1,15 @@ import { Dialog, + DialogBody, DialogContent, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import type { TProjectMetadata } from "@/types/project"; import { formatDate } from "@/utils/date"; import { formatTimeCode } from "@/lib/time"; +import { Button } from "../ui/button"; function InfoRow({ label, @@ -16,7 +19,7 @@ function InfoRow({ value: string | React.ReactNode; }) { return ( -
+
{label} {value}
@@ -42,14 +45,14 @@ export function ProjectInfoDialog({ return ( - + event.preventDefault()}> {project.name} -
+ } /> -
+ + + + +
); diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx index 1f8c56c3..6d2fe444 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -74,10 +74,10 @@ export function PropertyGroup({ diff --git a/apps/web/src/components/editor/timeline/audio-waveform.tsx b/apps/web/src/components/editor/timeline/audio-waveform.tsx index 08b14392..bbc5cb83 100644 --- a/apps/web/src/components/editor/timeline/audio-waveform.tsx +++ b/apps/web/src/components/editor/timeline/audio-waveform.tsx @@ -166,7 +166,7 @@ export function AudioWaveform({ )}
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 58dc7f5e..1d6ccae1 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -3,6 +3,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Delete02Icon, + TaskAdd02Icon, ViewIcon, ViewOffSlashIcon, VolumeHighIcon, @@ -36,6 +37,7 @@ import { canTracktHaveAudio, canTrackBeHidden, getTimelineZoomMin, + isMainTrack, } from "@/lib/timeline"; import { TimelineToolbar } from "./timeline-toolbar"; 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 { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { DragLine } from "./drag-line"; +import { invokeAction } from "@/lib/actions"; export function Timeline() { const tracksContainerHeight = { min: 0, max: 800 }; @@ -181,7 +184,7 @@ export function Timeline() { return (
+ {process.env.NODE_ENV === "development" && + isMainTrack(track) && ( +
+ )} {canTracktHaveAudio(track) && ( )} - {canTrackBeHidden(track) && ( )} -
@@ -397,6 +402,15 @@ export function Timeline() {
+ } + onClick={(e) => { + e.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + { e.stopPropagation(); diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index e0e5b374..fd292ad8 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -1,17 +1,5 @@ "use client"; -import { - Scissors, - Trash2, - Copy, - Search, - RefreshCw, - EyeOff, - Eye, - Volume2, - VolumeX, - ArrowUpDown, -} from "lucide-react"; import { useEditor } from "@/hooks/use-editor"; import { useAssetsPanelStore } from "@/stores/assets-panel-store"; import AudioWaveform from "./audio-waveform"; @@ -39,9 +27,35 @@ import type { } from "@/types/timeline"; import type { MediaAsset } from "@/types/assets"; 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 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 { element: TimelineElementType; @@ -112,17 +126,6 @@ export function TimelineElement({ displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * 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 }) => { event.stopPropagation(); if (hasMediaId(element)) { @@ -162,31 +165,16 @@ export function TimelineElement({ />
- - handleAction({ action: "split-selected", event })} - > - - {selectedElements.length > 1 && isCurrentElementSelected - ? `Split ${selectedElements.length} elements at playhead` - : "Split at playhead"} - - 1} - isCurrentElementSelected={isCurrentElementSelected} - selectedCount={selectedElements.length} - onClick={(event) => handleAction({ action: "copy-selected", event })} - /> + + }> + Split + + {canElementHaveAudio(element) && hasAudio && ( 1} isCurrentElementSelected={isCurrentElementSelected} isMuted={isMuted} - selectedCount={selectedElements.length} - onClick={(event) => - handleAction({ action: "toggle-elements-muted-selected", event }) - } /> )} {canElementBeHidden(element) && ( @@ -194,36 +182,26 @@ export function TimelineElement({ element={element} isMultipleSelected={selectedElements.length > 1} isCurrentElementSelected={isCurrentElementSelected} - selectedCount={selectedElements.length} - onClick={(event) => - handleAction({ - action: "toggle-elements-visibility-selected", - event, - }) - } /> )} {selectedElements.length === 1 && ( - - handleAction({ action: "duplicate-selected", event }) - } - > - - Duplicate {element.type === "text" ? "text" : "clip"} - + }> + Duplicate + )} {selectedElements.length === 1 && hasMediaId(element) && ( <> } onClick={(event) => handleRevealInMedia({ event })} > - - Reveal in media + Reveal media - - - Replace clip (Coming soon) + } + disabled + > + Replace media )} @@ -233,9 +211,6 @@ export function TimelineElement({ isCurrentElementSelected={isCurrentElementSelected} elementType={element.type} selectedCount={selectedElements.length} - onClick={(event) => - handleAction({ action: "delete-selected", event }) - } /> @@ -300,9 +275,15 @@ function ElementInner({ : canElementBeHidden(element) && element.hidden) && (
{hasAudio ? ( - + ) : ( - + )}
)} @@ -463,66 +444,38 @@ function ElementContent({ ); } -function CopyMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - selectedCount, - onClick, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - selectedCount: number; - onClick: (e: React.MouseEvent) => void; -}) { +function CopyMenuItem() { return ( - - - {isMultipleSelected && isCurrentElementSelected - ? `Copy ${selectedCount} elements` - : "Copy element"} - + }> + Copy + ); } function MuteMenuItem({ - element, isMultipleSelected, isCurrentElementSelected, isMuted, - selectedCount, - onClick, }: { - element: TimelineElementType; isMultipleSelected: boolean; isCurrentElementSelected: boolean; isMuted: boolean; - selectedCount: number; - onClick: (e: React.MouseEvent) => void; }) { const getIcon = () => { if (isMultipleSelected && isCurrentElementSelected) { - return ; + return ; } return isMuted ? ( - + ) : ( - + ); }; - const getLabel = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return `Toggle mute ${selectedCount} elements`; - } - const suffix = element.type === "text" ? "text" : "clip"; - return isMuted ? `Unmute ${suffix}` : `Mute ${suffix}`; - }; - return ( - - {getIcon()} - {getLabel()} - + + {isMuted ? "Unmute" : "Mute"} + ); } @@ -530,41 +483,28 @@ function VisibilityMenuItem({ element, isMultipleSelected, isCurrentElementSelected, - selectedCount, - onClick, }: { element: TimelineElementType; isMultipleSelected: boolean; isCurrentElementSelected: boolean; - selectedCount: number; - onClick: (e: React.MouseEvent) => void; }) { const isHidden = canElementBeHidden(element) && element.hidden; const getIcon = () => { if (isMultipleSelected && isCurrentElementSelected) { - return ; + return ; } return isHidden ? ( - + ) : ( - + ); }; - const getLabel = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return `Toggle visibility ${selectedCount} elements`; - } - const suffix = element.type === "text" ? "text" : "clip"; - return isHidden ? `Show ${suffix}` : `Hide ${suffix}`; - }; - return ( - - {getIcon()} - {getLabel()} - + + {isHidden ? "Show" : "Hide"} + ); } @@ -573,23 +513,42 @@ function DeleteMenuItem({ isCurrentElementSelected, elementType, selectedCount, - onClick, }: { isMultipleSelected: boolean; isCurrentElementSelected: boolean; elementType: TimelineElementType["type"]; selectedCount: number; - onClick: (e: React.MouseEvent) => void; }) { return ( - } > - {isMultipleSelected && isCurrentElementSelected ? `Delete ${selectedCount} elements` : `Delete ${elementType === "text" ? "text" : "clip"}`} + + ); +} + +function ActionMenuItem({ + action, + children, + ...props +}: Omit, "onClick" | "textRight"> & { + action: TAction; +}) { + return ( + { + event.stopPropagation(); + invokeAction(action); + }} + textRight={getDisplayShortcut(action)} + {...props} + > + {children} ); } diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index cb1a4f40..60ac9520 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -6,11 +6,7 @@ import { TooltipContent, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; -import { - SkipBack, - SplitSquareHorizontal, - LayersIcon, -} from "lucide-react"; +import { SkipBack, SplitSquareHorizontal } from "lucide-react"; import { SplitButton, SplitButtonLeft, @@ -38,10 +34,6 @@ import { PauseIcon, PlayIcon, Copy01Icon, - ArrowLeft03Icon, - ArrowRight03Icon, - LayoutAlignLeftIcon, - LayoutAlignRightIcon, AlignLeftIcon, AlignRightIcon, Layers01Icon, @@ -136,7 +128,7 @@ function ToolbarLeftSection() { icon={} tooltip="Split element" onClick={({ event }) => - handleAction({ action: "split-selected", event }) + handleAction({ action: "split", event }) } /> @@ -144,7 +136,7 @@ function ToolbarLeftSection() { icon={} tooltip="Split left" onClick={({ event }) => - handleAction({ action: "split-selected-right", event }) + handleAction({ action: "split-left", event }) } /> @@ -152,7 +144,7 @@ function ToolbarLeftSection() { icon={} tooltip="Split right" onClick={({ event }) => - handleAction({ action: "split-selected-left", event }) + handleAction({ action: "split-right", event }) } /> diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index e83a631d..7c78fc57 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -3,7 +3,7 @@ import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri"; import { FaGithub } from "react-icons/fa6"; import Image from "next/image"; import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; -import { capitalizeFirstLetter } from "@/utils/strings"; +import { capitalizeFirstLetter } from "@/utils/string"; type Category = "resources" | "company"; diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 53c5554d..83bf5243 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -74,7 +74,7 @@ export function Header() { - diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index e01b0fb6..d98fc0c2 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -30,9 +30,10 @@ export function Hero() {
+ + + +
+
+ + + + + + \ No newline at end of file diff --git a/html/dialog_designs.html b/html/dialog_designs.html index 6b5be497..a93f7d65 100644 --- a/html/dialog_designs.html +++ b/html/dialog_designs.html @@ -25,18 +25,15 @@
-

Dialog Design Explorations

-

Three interactions for the same brand language. Click buttons to open.

+

Project Info Explorations

+

Two layout approaches using the same "Structured" brand style.

- - -
@@ -44,125 +41,145 @@
- - - - - - - -