+
{children}
diff --git a/src/components/layout/dark-mode.tsx b/src/components/layout/dark-mode.tsx
index 29b2a6e..5977ef4 100644
--- a/src/components/layout/dark-mode.tsx
+++ b/src/components/layout/dark-mode.tsx
@@ -5,36 +5,20 @@ import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
export function ModeToggle({ style }: { style?: React.CSSProperties }) {
- const { setTheme } = useTheme();
+ const { resolvedTheme, setTheme } = useTheme();
return (
-
-
-
-
-
- setTheme("light")}>
- Light
-
- setTheme("dark")}>
- Dark
-
- setTheme("system")}>
- System
-
-
-
+
);
}
diff --git a/src/components/nle/index.tsx b/src/components/nle/index.tsx
index 6f2dc1d..b83a3a9 100644
--- a/src/components/nle/index.tsx
+++ b/src/components/nle/index.tsx
@@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
import { KeyboardShortcutDisplay } from "@/components/nle/keyboard-shortcut-display";
import { ScrollContext } from "@/components/nle/scroll-context";
import { TimelineTicks } from "@/components/nle/timeline-ticks";
-import { Play, Pause, FastForward, Rewind, Monitor, Eye, ZoomIn, ZoomOut } from "lucide-react";
+import { Play, Pause, FastForward, Rewind, Monitor, Eye, ZoomIn, ZoomOut, Heading1, Heading2, Heading3, Image, AlignLeft, List, Video } from "lucide-react";
import { ContentRenderer } from "@/components/nle/content-renderer";
import { Sequence } from "@/components/nle/sequence";
import { SequenceSelector } from "@/components/nle/sequence-selector";
@@ -19,7 +19,6 @@ import "@/styles/nle.css";
const EditorialInterfaceComponent = ({ markdown }: { markdown: string }) => {
const [scrollPercentage, setScrollPercentage] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
- const [playheadPosition, setPlayheadPosition] = useState(0);
const [isScrolling, setIsScrolling] = useState(false);
const [ffwState, setFfwState] = useState(false);
const [rewindState, setRewindState] = useState(false);
@@ -95,6 +94,11 @@ const EditorialInterfaceComponent = ({ markdown }: { markdown: string }) => {
}
}, [minZoomLevel, zoomLevel]);
+ // Derive playhead position from scroll percentage and calculated timeline width.
+ // Uses calculatedTimelineWidth (computed synchronously from state) instead of
+ // timelineWidth (updated async by ResizeObserver) to stay in sync after zoom changes.
+ const playheadPosition = scrollPercentage * calculatedTimelineWidth;
+
const verticalSectionRef = useRef(null);
const playButtonRef = useRef(null);
const playheadRef = useRef(null);
@@ -206,17 +210,6 @@ const EditorialInterfaceComponent = ({ markdown }: { markdown: string }) => {
// };
// }, [handleScroll]);
- // Update the playhead position and height calculation
- // Separate concerns: ResizeObserver for size changes, effect for position updates
- useEffect(() => {
- if (timelineWrapperRef.current) {
- const width = timelineWrapperRef.current.scrollWidth;
- const position = scrollPercentage * width;
- setPlayheadPosition(position);
- setTimelineWidth(width);
- }
- }, [scrollPercentage, zoomLevel]);
-
// ResizeObserver - track both scroll width (content) and client width (viewport)
useEffect(() => {
const updateTimelineDimensions = () => {
@@ -253,13 +246,7 @@ const EditorialInterfaceComponent = ({ markdown }: { markdown: string }) => {
setIsPlaying(false);
setFfwState(false);
setRewindState(false);
-
- if (!timelineWrapperRef.current) return;
-
- // Update playhead position
- const timelineWidth = timelineWrapperRef.current.scrollWidth;
- const newPosition = percentage * timelineWidth;
- setPlayheadPosition(newPosition);
+
setScrollPercentage(percentage);
}, []);
@@ -310,43 +297,75 @@ const EditorialInterfaceComponent = ({ markdown }: { markdown: string }) => {
}
}, []);
- const handlePlayheadDrag = useCallback((e: any, data: { x: number }) => {
+ const handlePlayheadDrag = useCallback((e: any) => {
setIsPlaying(false);
setFfwState(false);
setRewindState(false);
if (!timelineWrapperRef.current) return;
- const timelineWidth = timelineWrapperRef.current.scrollWidth;
- const maxX = timelineWidth - 2;
- const clampedX = Math.max(0, Math.min(maxX, data.x));
- const newPercentage = clampedX / timelineWidth;
+ // Compute position directly from the mouse event relative to the timeline wrapper,
+ // bypassing react-draggable's internal coordinate tracking which can drift.
+ const wrapperRect = timelineWrapperRef.current.getBoundingClientRect();
+ const posInContent = e.clientX - wrapperRect.left + timelineWrapperRef.current.scrollLeft;
+ const newPercentage = Math.max(0, Math.min(1, posInContent / calculatedTimelineWidth));
- setPlayheadPosition(clampedX);
setScrollPercentage(newPercentage);
- // Auto-scroll timeline if playhead is dragged near the left edge
+ // Auto-scroll timeline if playhead is dragged near edges
const viewportWidth = timelineWrapperRef.current.clientWidth;
const currentScrollLeft = timelineWrapperRef.current.scrollLeft;
- const playheadVisualPosition = clampedX - currentScrollLeft;
-
- // If playhead is too close to left edge (within 50px), scroll left
+ const playheadVisualPosition = posInContent - currentScrollLeft;
+
if (playheadVisualPosition < 50 && currentScrollLeft > 0) {
- const newScrollLeft = Math.max(0, clampedX - 100); // Keep playhead 100px from edge
+ const newScrollLeft = Math.max(0, posInContent - 100);
timelineWrapperRef.current.scrollLeft = newScrollLeft;
if (timelineTicksRef.current) {
timelineTicksRef.current.scrollLeft = newScrollLeft;
}
- }
- // If playhead is too close to right edge, scroll right
- else if (playheadVisualPosition > viewportWidth - 50) {
- const newScrollLeft = clampedX - viewportWidth + 100;
+ } else if (playheadVisualPosition > viewportWidth - 50) {
+ const newScrollLeft = posInContent - viewportWidth + 100;
timelineWrapperRef.current.scrollLeft = newScrollLeft;
if (timelineTicksRef.current) {
timelineTicksRef.current.scrollLeft = newScrollLeft;
}
}
- }, []);
+ }, [calculatedTimelineWidth]);
+
+ // Handle click-to-snap on the timeline track area: snap playhead on mousedown,
+ // then follow through with drag via document-level mousemove/mouseup.
+ const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
+ // Only handle left clicks, and ignore clicks on the playhead itself
+ if (e.button !== 0) return;
+ if (playheadRef.current && playheadRef.current.contains(e.target as Node)) return;
+
+ e.preventDefault();
+ setIsPlaying(false);
+ setFfwState(false);
+ setRewindState(false);
+
+ if (!timelineWrapperRef.current) return;
+
+ const computePercentage = (clientX: number) => {
+ const wrapperRect = timelineWrapperRef.current!.getBoundingClientRect();
+ const posInContent = clientX - wrapperRect.left + timelineWrapperRef.current!.scrollLeft;
+ return Math.max(0, Math.min(1, posInContent / calculatedTimelineWidth));
+ };
+
+ // Snap to click position immediately
+ setScrollPercentage(computePercentage(e.clientX));
+
+ const onMouseMove = (moveEvent: MouseEvent) => {
+ setScrollPercentage(computePercentage(moveEvent.clientX));
+ };
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ }, [calculatedTimelineWidth]);
// Update keyboard shortcuts
useEffect(() => {
@@ -539,12 +558,13 @@ const EditorialInterfaceComponent = ({ markdown }: { markdown: string }) => {
const deltaTime = (timestamp - lastTimestamp) / 1000;
lastTimestamp = timestamp;
+ let hitBounds = false;
+
setScrollPercentage((prev) => {
let newPercentage = prev;
const speedMultiplier = getSpeedMultiplier();
if (isPlaying) {
- // Use actual delta time instead of hardcoded 1/60
newPercentage += percentagePerSecond * deltaTime;
} else if (ffwState) {
newPercentage += (percentagePerSecond * speedMultiplier) * deltaTime;
@@ -557,19 +577,21 @@ const EditorialInterfaceComponent = ({ markdown }: { markdown: string }) => {
// Clamp between 0 and 1
newPercentage = Math.max(0, Math.min(1, newPercentage));
- // Stop playing if we hit the bounds
if (newPercentage >= 1 || newPercentage <= 0) {
- // Schedule state updates for the next batch to avoid infinite loop
- Promise.resolve().then(() => {
- setIsPlaying(false);
- setFfwState(false);
- setRewindState(false);
- });
+ hitBounds = true;
}
return newPercentage;
});
+ // Stop playback outside the updater to avoid cascading state updates
+ if (hitBounds) {
+ setIsPlaying(false);
+ setFfwState(false);
+ setRewindState(false);
+ return; // Don't schedule next frame
+ }
+
animationId = requestAnimationFrame(animate);
};
@@ -612,10 +634,10 @@ const EditorialInterfaceComponent = ({ markdown }: { markdown: string }) => {
}}
/>
*/}
-
@@ -630,15 +652,14 @@ const EditorialInterfaceComponent = ({ markdown }: { markdown: string }) => {
/>