summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor-header.tsx77
-rw-r--r--apps/web/src/components/editor/snap-indicator.tsx29
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx5
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx12
-rw-r--r--apps/web/src/components/editor/timeline/timeline-playhead.tsx75
-rw-r--r--apps/web/src/components/onboarding.tsx7
-rw-r--r--apps/web/src/components/ui/command.tsx9
-rw-r--r--apps/web/src/components/ui/visually-hidden.tsx32
8 files changed, 115 insertions, 131 deletions
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx
index 788ef60d..837bd97a 100644
--- a/apps/web/src/components/editor-header.tsx
+++ b/apps/web/src/components/editor-header.tsx
@@ -11,79 +11,19 @@ import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
import { useState, useRef } from "react";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
-import { useMediaStore } from "@/stores/media-store";
-import {
- VideoExporter,
- downloadBlob,
- type ExportOptions,
-} from "@/lib/export-utils";
export function EditorHeader() {
- const { getTotalDuration, tracks } = useTimelineStore();
+ const { getTotalDuration } = useTimelineStore();
const { activeProject, renameProject } = useProjectStore();
- const { mediaItems } = useMediaStore();
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(activeProject?.name || "");
- const [isExporting, setIsExporting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
- const handleExport = async () => {
- if (!activeProject) {
- toast.error("No active project to export");
- return;
- }
-
- if (tracks.length === 0) {
- toast.error("Timeline is empty - nothing to export");
- return;
- }
-
- setIsExporting(true);
-
- try {
- const exportOptions: ExportOptions = {
- format: "mp4",
- quality: "medium",
- resolution: "1080p",
- fps: activeProject.fps || 30,
- };
-
- const exporter = new VideoExporter((progress) => {
- toast.loading(
- `${progress.message} (${Math.round(progress.progress)}%)`,
- {
- id: "export-progress",
- }
- );
- });
-
- const exportedBlob = await exporter.exportProject(
- activeProject,
- tracks,
- mediaItems,
- exportOptions
- );
-
- // Download the exported video
- const filename = `${activeProject.name}.${exportOptions.format}`;
- downloadBlob(exportedBlob, filename);
-
- toast.success("Video exported successfully!", {
- id: "export-progress",
- });
-
- exporter.cleanup();
- } catch (error) {
- console.error("Export failed:", error);
- toast.error(
- `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
- {
- id: "export-progress",
- }
- );
- } finally {
- setIsExporting(false);
- }
+ const handleExport = () => {
+ // TODO: Implement export functionality
+ // NOTE: This is already being worked on
+ console.log("Export project");
+ window.open("https://youtube.com/watch?v=dQw4w9WgXcQ", "_blank");
};
const handleNameClick = () => {
@@ -169,12 +109,9 @@ export function EditorHeader() {
variant="primary"
className="h-7 text-xs"
onClick={handleExport}
- disabled={isExporting}
>
<Download className="h-4 w-4" />
- <span className="text-sm">
- {isExporting ? "Exporting..." : "Export"}
- </span>
+ <span className="text-sm">Export</span>
</Button>
</nav>
);
diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx
index 302a52bb..053d119e 100644
--- a/apps/web/src/components/editor/snap-indicator.tsx
+++ b/apps/web/src/components/editor/snap-indicator.tsx
@@ -3,6 +3,7 @@
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import type { TimelineTrack } from "@/types/timeline";
+import { useState, useEffect } from "react";
interface SnapIndicatorProps {
snapPoint: SnapPoint | null;
@@ -11,6 +12,7 @@ interface SnapIndicatorProps {
tracks: TimelineTrack[];
timelineRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
}
export function SnapIndicator({
@@ -20,7 +22,29 @@ export function SnapIndicator({
tracks,
timelineRef,
trackLabelsRef,
+ tracksScrollRef,
}: SnapIndicatorProps) {
+ const [scrollLeft, setScrollLeft] = useState(0);
+
+ // Track scroll position to lock snap indicator to frame
+ useEffect(() => {
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+
+ if (!tracksViewport) return;
+
+ const handleScroll = () => {
+ setScrollLeft(tracksViewport.scrollLeft);
+ };
+
+ // Set initial scroll position
+ setScrollLeft(tracksViewport.scrollLeft);
+
+ tracksViewport.addEventListener("scroll", handleScroll);
+ return () => tracksViewport.removeEventListener("scroll", handleScroll);
+ }, [tracksScrollRef]);
+
if (!isVisible || !snapPoint) {
return null;
}
@@ -34,9 +58,10 @@ export function SnapIndicator({
? trackLabelsRef.current.offsetWidth
: 0;
- const leftPosition =
- trackLabelsWidth +
+ // Calculate position locked to timeline content (accounting for scroll)
+ const timelinePosition =
snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
return (
<div
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index bcaece89..bdb4433c 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -537,12 +537,13 @@ export function Timeline() {
tracks={tracks}
timelineRef={timelineRef}
trackLabelsRef={trackLabelsRef}
+ tracksScrollRef={tracksScrollRef}
isVisible={showSnapIndicator}
/>
{/* Timeline Header with Ruler */}
<div className="flex bg-panel sticky top-0 z-10">
{/* Track Labels Header */}
- <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
+ <div className="w-48 flex-shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2">
{/* Empty space */}
<span className="text-sm font-medium text-muted-foreground opacity-0">
.
@@ -651,7 +652,7 @@ export function Timeline() {
{tracks.length > 0 && (
<div
ref={trackLabelsRef}
- className="w-48 flex-shrink-0 border-r border-black overflow-y-auto"
+ className="w-48 flex-shrink-0 border-r border-black overflow-y-auto z-[200] bg-panel"
data-track-labels
>
<ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index c66e9fcb..f581bbf0 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -157,16 +157,16 @@ export function TimelineElement({
if (!file) return;
try {
- const success = await replaceElementMedia(track.id, element.id, file);
- if (success) {
+ const result = await replaceElementMedia(track.id, element.id, file);
+ if (result.success) {
toast.success("Clip replaced successfully");
} else {
- toast.error("Failed to replace clip");
+ toast.error(result.error || "Failed to replace clip");
}
} catch (error) {
- toast.error("Failed to replace clip");
- console.log(
- JSON.stringify({ error: "Failed to replace clip", details: error })
+ console.error("Unexpected error replacing clip:", error);
+ toast.error(
+ `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
};
diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
index d0aea7fa..b33166c0 100644
--- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
@@ -1,11 +1,8 @@
"use client";
-import { useRef } from "react";
+import { useRef, useState, useEffect } from "react";
import { TimelineTrack } from "@/types/timeline";
-import {
- TIMELINE_CONSTANTS,
- getTotalTracksHeight,
-} from "@/constants/timeline-constants";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps {
@@ -39,6 +36,8 @@ export function TimelinePlayhead({
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
+ const [scrollLeft, setScrollLeft] = useState(0);
+
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
currentTime,
duration,
@@ -50,6 +49,25 @@ export function TimelinePlayhead({
playheadRef,
});
+ // Track scroll position to lock playhead to frame
+ useEffect(() => {
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+
+ if (!tracksViewport) return;
+
+ const handleScroll = () => {
+ setScrollLeft(tracksViewport.scrollLeft);
+ };
+
+ // Set initial scroll position
+ setScrollLeft(tracksViewport.scrollLeft);
+
+ tracksViewport.addEventListener("scroll", handleScroll);
+ return () => tracksViewport.removeEventListener("scroll", handleScroll);
+ }, [tracksScrollRef]);
+
// Use timeline container height minus a few pixels for breathing room
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
@@ -59,9 +77,51 @@ export function TimelinePlayhead({
tracks.length > 0 && trackLabelsRef?.current
? trackLabelsRef.current.offsetWidth
: 0;
- const leftPosition =
- trackLabelsWidth +
+
+ // Calculate position locked to timeline content (accounting for scroll)
+ const timelinePosition =
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
+
+ // Get the timeline content width and viewport width for right boundary
+ const timelineContentWidth =
+ duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ const viewportWidth = tracksViewport?.clientWidth || 1000;
+
+ // Constrain playhead to never appear outside the timeline area
+ const leftBoundary = trackLabelsWidth;
+ const rightBoundary = Math.min(
+ trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
+ trackLabelsWidth + viewportWidth // Don't go beyond viewport
+ );
+
+ const leftPosition = Math.max(
+ leftBoundary,
+ Math.min(rightBoundary, rawLeftPosition)
+ );
+
+ // Debug logging when playhead might go outside
+ if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) {
+ console.log(
+ "PLAYHEAD VISUAL DEBUG:",
+ JSON.stringify({
+ playheadPosition,
+ timelinePosition,
+ trackLabelsWidth,
+ scrollLeft,
+ rawLeftPosition,
+ constrainedLeftPosition: leftPosition,
+ leftBoundary,
+ rightBoundary,
+ timelineContentWidth,
+ viewportWidth,
+ zoomLevel,
+ })
+ );
+ }
return (
<div
@@ -72,7 +132,6 @@ export function TimelinePlayhead({
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
- zIndex: 100,
}}
onMouseDown={handlePlayheadMouseDown}
>
diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx
index c118e7c2..d72ac0f6 100644
--- a/apps/web/src/components/onboarding.tsx
+++ b/apps/web/src/components/onboarding.tsx
@@ -5,7 +5,6 @@ import { Button } from "./ui/button";
import { ArrowRightIcon } from "lucide-react";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
-import { VisuallyHidden } from "./ui/visually-hidden";
export function Onboarding() {
const [step, setStep] = useState(0);
@@ -56,7 +55,7 @@ export function Onboarding() {
return (
<div className="space-y-5">
<div className="space-y-3">
- <Title title="⚠️ This is a super early beta!" />
+ <Title title={getStepTitle()} />
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
</div>
@@ -67,7 +66,7 @@ export function Onboarding() {
return (
<div className="space-y-5">
<div className="space-y-3">
- <Title title="🦋 Have fun testing!" />
+ <Title title={getStepTitle()} />
<Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
</div>
<NextButton onClick={handleClose}>Finish</NextButton>
@@ -82,7 +81,7 @@ export function Onboarding() {
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px] !outline-none">
<DialogTitle>
- <VisuallyHidden>{getStepTitle()}</VisuallyHidden>
+ <span className="sr-only">{getStepTitle()}</span>
</DialogTitle>
{renderStepContent()}
</DialogContent>
diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx
index b91cbd3d..11b27041 100644
--- a/apps/web/src/components/ui/command.tsx
+++ b/apps/web/src/components/ui/command.tsx
@@ -1,14 +1,12 @@
"use client";
import * as React from "react";
+import { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "../../lib/utils";
-import { Dialog, DialogContent, DialogTitle } from "./dialog";
-import { VisuallyHidden } from "./visually-hidden";
-
-type DialogProps = React.ComponentProps<typeof Dialog>;
+import { Dialog, DialogContent } from "./dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
@@ -29,9 +27,6 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
- <DialogTitle>
- <VisuallyHidden>Command Menu</VisuallyHidden>
- </DialogTitle>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
diff --git a/apps/web/src/components/ui/visually-hidden.tsx b/apps/web/src/components/ui/visually-hidden.tsx
deleted file mode 100644
index 7d6fc435..00000000
--- a/apps/web/src/components/ui/visually-hidden.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { cn } from "../../lib/utils";
-
-/**
- * VisuallyHidden component provides content that is accessible to screen readers
- * but visually hidden from sighted users. This is useful for providing context
- * or labels that are necessary for accessibility but would be redundant visually.
- *
- * Based on the Radix UI VisuallyHidden primitive pattern.
- */
-const VisuallyHidden = React.forwardRef<
- HTMLSpanElement,
- React.ComponentPropsWithoutRef<"span">
->(({ className, ...props }, ref) => (
- <span
- ref={ref}
- className={cn(
- // Screen reader only styles - visually hidden but accessible
- "absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0",
- // Alternative approach using sr-only if preferred
- // "sr-only",
- className
- )}
- {...props}
- />
-));
-
-VisuallyHidden.displayName = "VisuallyHidden";
-
-export { VisuallyHidden };