From b266678fe4bb8c4053688fd037693fa2f930ac14 Mon Sep 17 00:00:00 2001 From: gantiro Date: Tue, 15 Jul 2025 22:51:38 +0700 Subject: Optimize timeline performance --- .../components/editor/properties-panel/index.tsx | 4 +- .../timeline-canvas-ruler-wrapper.tsx | 24 ++ .../timeline-canvas/timeline-canvas-ruler.tsx | 77 +++++++ .../src/components/editor/timeline-playhead.tsx | 32 ++- apps/web/src/components/editor/timeline.tsx | 245 ++++++++------------- 5 files changed, 207 insertions(+), 175 deletions(-) create mode 100644 apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx create mode 100644 apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx (limited to 'apps/web/src/components/editor') diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 9e28850f..c3290355 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -83,12 +83,12 @@ export function PropertiesPanel() { ); if (mediaItem?.type === "audio") { - return ; + return ; } return (
- +
); } diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx new file mode 100644 index 00000000..c0efc19e --- /dev/null +++ b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx @@ -0,0 +1,24 @@ +import { TimelineCanvasRulerWrapperProps } from "@/types/timeline"; +import React, { forwardRef } from "react"; + +/** + * Wrapper div around the canvas-based timeline ruler. + * Captures mouse events for scrubbing and ensures pointer events are properly enabled. + */ +const TimelineCanvasRulerWrapper = forwardRef< + HTMLDivElement, + TimelineCanvasRulerWrapperProps +>(({ children, onMouseDown, className = "" }, ref) => { + return ( +
+ {children} +
+ ); +}); + +export default TimelineCanvasRulerWrapper; diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx new file mode 100644 index 00000000..d004c465 --- /dev/null +++ b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx @@ -0,0 +1,77 @@ +import { TimelineCanvasRulerProps } from "@/types/timeline"; +import React, { useRef, useEffect } from "react"; + +/** + * TimelineCanvasRuler renders the timeline ticks and labels using Canvas. + * Should be wrapped by TimelineCanvasRulerWrapper for interaction handling. + */ +export default function TimelineCanvasRuler({ + zoomLevel, + duration, + width, + height = 20, +}: TimelineCanvasRulerProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, width, height); + + const pixelsPerSecond = 50 * zoomLevel; + + const getTimeInterval = () => { + if (pixelsPerSecond >= 200) return 0.1; + if (pixelsPerSecond >= 100) return 0.5; + if (pixelsPerSecond >= 50) return 1; + if (pixelsPerSecond >= 25) return 2; + if (pixelsPerSecond >= 12) return 5; + if (pixelsPerSecond >= 6) return 10; + return 30; + }; + + const mainInterval = getTimeInterval(); + const tickPerMain = 5; + const subInterval = mainInterval / tickPerMain; + + const totalTicks = Math.ceil(duration / subInterval) + 1; + + for (let i = 0; i < totalTicks; i++) { + const time = i * subInterval; + const x = time * pixelsPerSecond; + + const isMain = i % tickPerMain === 0; + + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, isMain ? height * 0.3 : height * 0.1); + ctx.strokeStyle = isMain ? "#999" : "#ccc"; + ctx.lineWidth = isMain ? 1 : 0.5; + ctx.stroke(); + + // Label + if (isMain) { + ctx.fillStyle = "#666"; + ctx.font = "10px sans-serif"; + const mins = Math.floor(time / 60); + const secs = Math.floor(time % 60); + let label = ""; + let xTranslate = 10; + if (mainInterval < 1) { + label = `${time.toFixed(1)}s`; + } else if (mins > 0) { + label = `${mins}:${secs.toString().padStart(2, "0")}`; + } else { + label = `${secs}s`; + xTranslate = 5; + } + ctx.fillText(label, x - xTranslate, height - 4); + } + } + }, [zoomLevel, duration, width, height]); + + return ( + + ); +} diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx index 78aed3a8..daee080e 100644 --- a/apps/web/src/components/editor/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -2,10 +2,7 @@ import { useRef } 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 { @@ -16,7 +13,6 @@ interface TimelinePlayheadProps { seek: (time: number) => void; rulerRef: React.RefObject; rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; trackLabelsRef?: React.RefObject; timelineRef: React.RefObject; playheadRef?: React.RefObject; @@ -31,7 +27,6 @@ export function TimelinePlayhead({ seek, rulerRef, rulerScrollRef, - tracksScrollRef, trackLabelsRef, timelineRef, playheadRef: externalPlayheadRef, @@ -39,15 +34,13 @@ export function TimelinePlayhead({ }: TimelinePlayheadProps) { const internalPlayheadRef = useRef(null); const playheadRef = externalPlayheadRef || internalPlayheadRef; - const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + const { playheadPosition, handleRulerMouseDown } = useTimelinePlayhead({ currentTime, duration, zoomLevel, seek, rulerRef, rulerScrollRef, - tracksScrollRef, - playheadRef, }); // Use timeline container height minus a few pixels for breathing room @@ -66,14 +59,14 @@ export function TimelinePlayhead({ return (
{/* The playhead line spanning full height */}
) { - const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ +}: Omit< + TimelinePlayheadProps, + | "tracks" + | "trackLabelsRef" + | "timelineRef" + | "tracksScrollRef" + | "playheadRef" +>) { + const { handleRulerMouseDown } = useTimelinePlayhead({ currentTime, duration, zoomLevel, seek, rulerRef, rulerScrollRef, - tracksScrollRef, - playheadRef, }); - return { handleRulerMouseDown, isDraggingRuler }; + return { handleRulerMouseDown }; } export { TimelinePlayhead as default }; diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index e83b64b9..7b7455e0 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -15,8 +15,9 @@ import { Video, Music, TypeIcon, - Magnet, Lock, + ZoomIn, + ZoomOut, } from "lucide-react"; import { Tooltip, @@ -54,6 +55,9 @@ import { getTotalTracksHeight, TIMELINE_CONSTANTS, } from "@/constants/timeline-constants"; +import { Slider } from "../ui/slider"; +import TimelineCanvasRuler from "./timeline-canvas/timeline-canvas-ruler"; +import TimelineCanvasRulerWrapper from "./timeline-canvas/timeline-canvas-ruler-wrapper"; export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their elements. @@ -92,10 +96,13 @@ export function Timeline() { const [isInTimeline, setIsInTimeline] = useState(false); // Timeline zoom functionality - const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ - containerRef: timelineRef, - isInTimeline, - }); + const { + zoomLevel, + zoomStep, + handleChangeZoomLevel, + handleChangeZoomStep, + handleWheel, + } = useTimelineZoom(); // Old marquee selection removed - using new SelectionBox component instead @@ -108,7 +115,6 @@ export function Timeline() { // Scroll synchronization and auto-scroll to playhead const rulerScrollRef = useRef(null); - const tracksScrollRef = useRef(null); const trackLabelsRef = useRef(null); const playheadRef = useRef(null); const trackLabelsScrollRef = useRef(null); @@ -125,8 +131,6 @@ export function Timeline() { seek, rulerRef, rulerScrollRef, - tracksScrollRef, - playheadRef, }); // Selection box functionality @@ -196,14 +200,14 @@ export function Timeline() { clearSelectedElements(); // Determine if we're clicking in ruler or tracks area - const isRulerClick = (e.target as HTMLElement).closest( - "[data-ruler-area]" + const isTracksAreaClick = (e.target as HTMLElement).closest( + "[data-tracks-area]" ); let mouseX: number; let scrollLeft = 0; - if (isRulerClick) { + if (isTracksAreaClick) { // Calculate based on ruler position const rulerContent = rulerScrollRef.current?.querySelector( "[data-radix-scroll-area-viewport]" @@ -213,14 +217,7 @@ export function Timeline() { mouseX = e.clientX - rect.left; scrollLeft = rulerContent.scrollLeft; } else { - // Calculate based on tracks content position - const tracksContent = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!tracksContent) return; - const rect = tracksContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = tracksContent.scrollLeft; + return; } const rawTime = Math.max( @@ -244,7 +241,6 @@ export function Timeline() { zoomLevel, seek, rulerScrollRef, - tracksScrollRef, clearSelectedElements, isSelecting, justFinishedSelecting, @@ -616,14 +612,11 @@ export function Timeline() { const rulerViewport = rulerScrollRef.current?.querySelector( "[data-radix-scroll-area-viewport]" ) as HTMLElement; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( "[data-radix-scroll-area-viewport]" ) as HTMLElement; - if (!rulerViewport || !tracksViewport) return; + if (!rulerViewport) return; // Horizontal scroll synchronization between ruler and tracks const handleRulerScroll = () => { @@ -631,7 +624,6 @@ export function Timeline() { if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; lastRulerSync.current = now; isUpdatingRef.current = true; - tracksViewport.scrollLeft = rulerViewport.scrollLeft; isUpdatingRef.current = false; }; const handleTracksScroll = () => { @@ -639,12 +631,10 @@ export function Timeline() { if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; lastTracksSync.current = now; isUpdatingRef.current = true; - rulerViewport.scrollLeft = tracksViewport.scrollLeft; isUpdatingRef.current = false; }; rulerViewport.addEventListener("scroll", handleRulerScroll); - tracksViewport.addEventListener("scroll", handleTracksScroll); // Vertical scroll synchronization between track labels and tracks content if (trackLabelsViewport) { @@ -654,7 +644,6 @@ export function Timeline() { return; lastVerticalSync.current = now; isUpdatingRef.current = true; - tracksViewport.scrollTop = trackLabelsViewport.scrollTop; isUpdatingRef.current = false; }; const handleTracksVerticalScroll = () => { @@ -663,30 +652,22 @@ export function Timeline() { return; lastVerticalSync.current = now; isUpdatingRef.current = true; - trackLabelsViewport.scrollTop = tracksViewport.scrollTop; isUpdatingRef.current = false; }; trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); - tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); return () => { rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); trackLabelsViewport.removeEventListener( "scroll", handleTrackLabelsScroll ); - tracksViewport.removeEventListener( - "scroll", - handleTracksVerticalScroll - ); }; } return () => { rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); }; }, []); @@ -846,6 +827,39 @@ export function Timeline() { Delete element (Delete) + + + + + Zoom Out + + handleChangeZoomStep(value[0])} + /> + + + + + Zoom In +
@@ -875,7 +889,6 @@ export function Timeline() { seek={seek} rulerRef={rulerRef} rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} trackLabelsRef={trackLabelsRef} timelineRef={timelineRef} playheadRef={playheadRef} @@ -893,114 +906,38 @@ export function Timeline() { /> {/* Timeline Header with Ruler */}
- {/* Track Labels Header */} -
- {/* Empty space */} - - . - -
+
- {/* Timeline Ruler */} -
+
-
- {/* Time markers */} - {(() => { - // Calculate appropriate time interval based on zoom level - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = - TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; - if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in - if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in - if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom - if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out - if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out - if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out - return 30; // Every 30s when extremely zoomed out - }; - - const interval = getTimeInterval(zoomLevel); - const markerCount = Math.ceil(duration / interval) + 1; - - return Array.from({ length: markerCount }, (_, i) => { - const time = i * interval; - if (time > duration) return null; - - const isMainMarker = - time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; - - return ( -
- - {(() => { - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (minutes > 0) { - return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (interval >= 1) { - return `${Math.floor(secs)}s`; - } else { - return `${secs.toFixed(1)}s`; - } - }; - return formatTime(time); - })()} - -
- ); - }).filter(Boolean); - })()} -
+ +
{/* Tracks Area */} -
- {/* Track Labels */} - {tracks.length > 0 && ( -
- -
+ +
+ {/* Track Labels */} + {tracks.length > 0 && ( +
+
{tracks.map((track) => (
))}
- -
- )} +
+ )} - {/* Timeline Tracks Content */} -
- - + {/* Timeline Tracks Content */} +
+
)}
- +
-
+
); -- cgit v1.3.1 From 5fc06257f26e68185042e4214b368f6658956ab6 Mon Sep 17 00:00:00 2001 From: gantiro Date: Tue, 15 Jul 2025 23:23:15 +0700 Subject: change zIndex for timeline-playhead --- apps/web/src/components/editor/timeline-playhead.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'apps/web/src/components/editor') diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx index daee080e..390abc3d 100644 --- a/apps/web/src/components/editor/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -59,7 +59,7 @@ export function TimelinePlayhead({ return (
Date: Tue, 15 Jul 2025 23:26:56 +0700 Subject: fix comment --- apps/web/src/components/editor/properties-panel/index.tsx | 8 ++++++-- .../components/editor/timeline-canvas/timeline-canvas-ruler.tsx | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) (limited to 'apps/web/src/components/editor') diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index c3290355..dc0dd4b2 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -83,12 +83,16 @@ export function PropertiesPanel() { ); if (mediaItem?.type === "audio") { - return ; + return ( +
+ +
+ ); } return (
- +
); } diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx index d004c465..c40cdbda 100644 --- a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx +++ b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx @@ -1,3 +1,4 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TimelineCanvasRulerProps } from "@/types/timeline"; import React, { useRef, useEffect } from "react"; @@ -19,7 +20,7 @@ export default function TimelineCanvasRuler({ ctx.clearRect(0, 0, width, height); - const pixelsPerSecond = 50 * zoomLevel; + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; const getTimeInterval = () => { if (pixelsPerSecond >= 200) return 0.1; -- cgit v1.3.1