diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-01 19:27:49 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-01 19:27:49 +0200 |
| commit | bd9ec023eefc4b5d26fa8b66c4338e97104f0f96 (patch) | |
| tree | 08350eaa107b27f026eebdb65df54b1ca25521fd /apps/web/src/components/editor/panels | |
| parent | f4b9f40ab1ba1cedf208bba708f4331d36bb30cc (diff) | |
| download | opencut-bd9ec023eefc4b5d26fa8b66c4338e97104f0f96.tar.gz | |
refactor: split constants by domain and isolate timeline panel from core
Diffstat (limited to 'apps/web/src/components/editor/panels')
23 files changed, 569 insertions, 68 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index e17e4d4a..031d2e6a 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -25,7 +25,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation"; import { useEditor } from "@/hooks/use-editor"; import { useFileUpload } from "@/hooks/use-file-upload"; import { invokeAction } from "@/lib/actions"; @@ -257,7 +257,7 @@ function MediaAssetDraggable({ startTime: number; }) => { const duration = - asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS; const element = buildElementFromMedia({ mediaId: asset.id, mediaType: asset.type, diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx index 7a81188f..bbceac7d 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx @@ -8,10 +8,10 @@ import { SectionTitle,
} from "@/components/section";
import {
- BLUR_INTENSITY_PRESETS,
- DEFAULT_BLUR_INTENSITY,
- DEFAULT_COLOR,
-} from "@/constants/project-constants";
+ BACKGROUND_BLUR_INTENSITY_PRESETS,
+ DEFAULT_BACKGROUND_BLUR_INTENSITY,
+ DEFAULT_BACKGROUND_COLOR,
+} from "@/lib/background/constants";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
import { colors } from "@/data/colors/solid";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
@@ -164,16 +164,16 @@ export function BackgroundContent() { const currentBlurIntensity = isBlurBackground
? (activeProject.settings.background as { blurIntensity: number })
.blurIntensity
- : DEFAULT_BLUR_INTENSITY;
+ : DEFAULT_BACKGROUND_BLUR_INTENSITY;
const currentBackgroundColor = isColorBackground
? (activeProject.settings.background as { color: string }).color
- : DEFAULT_COLOR;
+ : DEFAULT_BACKGROUND_COLOR;
const canvasSize = activeProject.settings.canvasSize;
const blurPreviews = useMemo(
() =>
- BLUR_INTENSITY_PRESETS.map((blur) => (
+ BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
<BlurPreview
key={blur.value}
blur={blur}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx index 1526a842..6c819a36 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -9,7 +9,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { FPS_PRESETS } from "@/constants/project-constants"; +import { FPS_PRESETS } from "@/lib/fps/constants"; import { useEditor } from "@/hooks/use-editor"; import { Section, diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx index 8fd38768..51e5d2d0 100644 --- a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx @@ -17,7 +17,7 @@ import { screenToCanvas,
} from "@/lib/preview/preview-coords";
import { clamp, isNearlyEqual } from "@/utils/math";
-import { PREVIEW_ZOOM } from "@/constants/editor-constants";
+import { PREVIEW_ZOOM } from "@/lib/preview/constants";
const MIDDLE_MOUSE_BUTTON = 1;
const IS_AT_FIT_EPSILON = 0.001;
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx index 340058d0..0be1ec46 100644 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -22,7 +22,7 @@ import { SelectItem, SelectSeparator, } from "@/components/ui/select"; -import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants"; +import { PREVIEW_ZOOM_PRESETS } from "@/lib/preview/constants"; import { usePreviewViewport } from "./preview-viewport"; import { GridPopover } from "./guide-popover"; import { usePreviewStore } from "@/stores/preview-store"; diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx index da86c626..cb10aac3 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx @@ -11,7 +11,7 @@ import { MAX_RETIME_RATE,
clampRetimeRate,
canMaintainPitch,
-} from "@/constants/retime-constants";
+} from "@/lib/retime/rate";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import {
Section,
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx index 4c58023b..11412c04 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -18,13 +18,13 @@ import { Button } from "@/components/ui/button"; import { uppercase } from "@/utils/string"; import { clamp, formatNumberForDisplay } from "@/utils/math"; import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_COLOR } from "@/constants/project-constants"; import { CORNER_RADIUS_MAX, + DEFAULT_TEXT_COLOR, CORNER_RADIUS_MIN, MAX_FONT_SIZE, MIN_FONT_SIZE, -} from "@/constants/text-constants"; +} from "@/lib/text/constants"; import { usePropertyDraft } from "../hooks/use-property-draft"; import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property"; import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; @@ -343,7 +343,7 @@ function BackgroundSection({ trackId: string; }) { const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_COLOR); + const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR); const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ startTime: element.startTime, duration: element.duration, diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index e2dc09f1..d4b1657e 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -6,10 +6,10 @@ import { useEditor } from "@/hooks/use-editor"; import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; import { - DEFAULT_BOOKMARK_COLOR, - TIMELINE_BOOKMARK_ROW_HEIGHT, -} from "@/constants/timeline-constants"; -import { DEFAULT_FPS } from "@/constants/project-constants"; + DEFAULT_TIMELINE_BOOKMARK_COLOR, +} from "./theme"; +import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout"; +import { DEFAULT_FPS } from "@/lib/fps/constants"; import { getSnappedSeekTime } from "opencut-wasm"; import { ArrowTurnBackwardIcon, @@ -83,12 +83,12 @@ export function TimelineBookmarksRow({ return ( <div className="relative flex-1 overflow-hidden" - style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }} + style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }} > <button className="relative w-full cursor-default select-none border-0 bg-transparent p-0" style={{ - height: TIMELINE_BOOKMARK_ROW_HEIGHT, + height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX, width: `${dynamicTimelineWidth}px`, }} aria-label="Timeline ruler" @@ -153,7 +153,7 @@ function TimelineBookmark({ const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel }); const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); - const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR; + const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR; const handleSeek = () => seekToBookmarkTime({ editor, time }); @@ -298,12 +298,16 @@ function BookmarkPopoverContent({ }) { const editor = useEditor(); const [draftColorHex, setDraftColorHex] = useState( - (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(), + (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR) + .replace("#", "") + .toUpperCase(), ); useEffect(() => { setDraftColorHex( - (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(), + (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR) + .replace("#", "") + .toUpperCase(), ); }, [bookmark.color]); @@ -357,7 +361,7 @@ function BookmarkPopoverContent({ /> {bookmark.color && bookmark.color.replace(/^#/, "").toUpperCase() !== - DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && ( + DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && ( <Button type="button" variant="text" diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx index a5a145b4..89a4c93f 100644 --- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx @@ -1,6 +1,6 @@ -import { getDropLineY } from "@/lib/timeline/drop-utils";
+import { getDropLineY } from "./drop-target";
import type { TimelineTrack, DropTarget } from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
+import { TIMELINE_LAYERS } from "./layers";
interface DragLineProps {
dropTarget: DropTarget | null;
diff --git a/apps/web/src/components/editor/panels/timeline/drop-target.ts b/apps/web/src/components/editor/panels/timeline/drop-target.ts new file mode 100644 index 00000000..dbd4efaf --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/drop-target.ts @@ -0,0 +1,251 @@ +import type { TimelineTrack, TimelineElement } from "@/lib/timeline"; +import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline"; +import { resolveTrackPlacement } from "@/lib/timeline/placement"; +import { TIMELINE_TRACK_GAP_PX } from "./layout"; +import { getTrackHeight } from "./track-layout"; + +function findElementAtPosition({ + mouseX, + tracks, + trackIndex, + targetElementTypes, + pixelsPerSecond, + zoomLevel, +}: { + mouseX: number; + tracks: TimelineTrack[]; + trackIndex: number; + targetElementTypes: string[]; + pixelsPerSecond: number; + zoomLevel: number; +}): { elementId: string; trackId: string } | null { + const time = mouseX / (pixelsPerSecond * zoomLevel); + const track = tracks[trackIndex]; + if (!track || !("elements" in track)) return null; + + const hit = track.elements.find( + (element: TimelineElement) => + targetElementTypes.includes(element.type) && + element.startTime <= time && + time < element.startTime + element.duration, + ); + if (!hit) return null; + return { elementId: hit.id, trackId: track.id }; +} + +function getTrackAtY({ + mouseY, + tracks, + verticalDragDirection, +}: { + mouseY: number; + tracks: TimelineTrack[]; + verticalDragDirection?: "up" | "down" | null; +}): { trackIndex: number; relativeY: number } | null { + let cumulativeHeight = 0; + + for (let i = 0; i < tracks.length; i++) { + const trackHeight = getTrackHeight({ type: tracks[i].type }); + const trackTop = cumulativeHeight; + const trackBottom = trackTop + trackHeight; + + if (mouseY >= trackTop && mouseY < trackBottom) { + return { + trackIndex: i, + relativeY: mouseY - trackTop, + }; + } + + if (i < tracks.length - 1 && verticalDragDirection) { + const gapTop = trackBottom; + const gapBottom = gapTop + TIMELINE_TRACK_GAP_PX; + if (mouseY >= gapTop && mouseY < gapBottom) { + const isDraggingUp = verticalDragDirection === "up"; + return { + trackIndex: isDraggingUp ? i : i + 1, + relativeY: isDraggingUp ? trackHeight - 1 : 0, + }; + } + } + + cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX; + } + + return null; +} + +const EMPTY_TARGET_ELEMENT = null; + +function fallbackNewTrackDropTarget({ + xPosition, +}: { + xPosition: number; +}): DropTarget { + return { + trackIndex: 0, + isNewTrack: true, + insertPosition: null, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; +} + +export function computeDropTarget({ + elementType, + mouseX, + mouseY, + tracks, + playheadTime, + isExternalDrop, + elementDuration, + pixelsPerSecond, + zoomLevel, + verticalDragDirection, + startTimeOverride, + excludeElementId, + targetElementTypes, +}: ComputeDropTargetParams): DropTarget { + const xPosition = + typeof startTimeOverride === "number" + ? startTimeOverride + : isExternalDrop + ? playheadTime + : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)); + + if (tracks.length === 0) { + const placementResult = resolveTrackPlacement({ + tracks, + elementType, + timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "below", + createNewTrackOnly: true, + }, + }); + const emptyTimelineResult = + placementResult?.kind === "newTrack" ? placementResult : null; + if (!emptyTimelineResult) { + return fallbackNewTrackDropTarget({ xPosition }); + } + + return { + trackIndex: emptyTimelineResult.insertIndex, + isNewTrack: true, + insertPosition: emptyTimelineResult.insertPosition, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; + } + + const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection }); + + if (!trackAtMouse) { + const isAboveAllTracks = mouseY < 0; + + const placementResult = resolveTrackPlacement({ + tracks, + elementType, + timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + strategy: { + type: "preferIndex", + trackIndex: isAboveAllTracks ? 0 : tracks.length - 1, + hoverDirection: isAboveAllTracks ? "above" : "below", + createNewTrackOnly: true, + }, + }); + const outOfBoundsResult = + placementResult?.kind === "newTrack" ? placementResult : null; + if (!outOfBoundsResult) { + return fallbackNewTrackDropTarget({ xPosition }); + } + + return { + trackIndex: outOfBoundsResult.insertIndex, + isNewTrack: true, + insertPosition: outOfBoundsResult.insertPosition, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; + } + + const { trackIndex, relativeY } = trackAtMouse; + const track = tracks[trackIndex]; + + if (targetElementTypes && targetElementTypes.length > 0) { + const targetElement = findElementAtPosition({ + mouseX, + tracks, + trackIndex, + targetElementTypes, + pixelsPerSecond, + zoomLevel, + }); + if (targetElement) { + return { + trackIndex, + isNewTrack: false, + insertPosition: null, + xPosition, + targetElement, + }; + } + } + + const trackHeight = getTrackHeight({ type: track.type }); + const placementResult = resolveTrackPlacement({ + tracks, + elementType, + timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + strategy: { + type: "preferIndex", + trackIndex, + hoverDirection: relativeY < trackHeight / 2 ? "above" : "below", + verticalDragDirection, + }, + }); + if (!placementResult) { + return fallbackNewTrackDropTarget({ xPosition }); + } + + if (placementResult.kind === "existingTrack") { + const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition; + + return { + trackIndex: placementResult.trackIndex, + isNewTrack: false, + insertPosition: null, + xPosition: adjustedXPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; + } + + return { + trackIndex: placementResult.insertIndex, + isNewTrack: true, + insertPosition: placementResult.insertPosition, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; +} + +export function getDropLineY({ + dropTarget, + tracks, +}: { + dropTarget: DropTarget; + tracks: TimelineTrack[]; +}): number { + const safeTrackIndex = Math.min( + Math.max(dropTarget.trackIndex, 0), + tracks.length, + ); + let y = 0; + + for (let i = 0; i < safeTrackIndex; i++) { + y += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX; + } + + return y; +} diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 819413e6..4114ae2c 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -38,20 +38,26 @@ import { SnapIndicator } from "./snap-indicator"; import type { SnapPoint } from "@/lib/timeline/snap-utils"; import type { TimelineTrack } from "@/lib/timeline"; import { - TIMELINE_CONSTANTS, - TRACK_GAP, TIMELINE_SCROLLBAR_SIZE_PX, -} from "@/constants/timeline-constants"; + TIMELINE_CONTENT_TOP_PADDING_PX, + TIMELINE_TRACK_GAP_PX, + TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX, +} from "./layout"; import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; import { - getTrackHeight, - getCumulativeHeightBefore, - getTotalTracksHeight, - canTracktHaveAudio, + canTrackHaveAudio, canTrackBeHidden, getTimelineZoomMin, getTimelinePaddingPx, } from "@/lib/timeline"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, +} from "./track-layout"; +import { SELECTED_TRACK_ROW_CLASS } from "./theme"; +import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction"; import { isMainTrack } from "@/lib/timeline/placement"; import { TimelineToolbar } from "./timeline-toolbar"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; @@ -67,7 +73,7 @@ import { useEditor } from "@/hooks/use-editor"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/lib/actions"; -import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing"; +import { resolveTimelineElementIntersections } from "./selection-hit-testing"; import { cn } from "@/utils/ui"; const TRACKS_CONTAINER_MAX_HEIGHT = 800; @@ -230,7 +236,7 @@ export function Timeline() { Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; const clamped = Math.sign(raw) * - Math.min(Math.abs(raw), TIMELINE_CONSTANTS.HORIZONTAL_WHEEL_STEP_PX); + Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX); tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped); } else { tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY); @@ -335,7 +341,7 @@ export function Timeline() { const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = - timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + timelineDuration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; const paddingPx = getTimelinePaddingPx({ containerWidth, zoomLevel, @@ -383,7 +389,7 @@ export function Timeline() { const timelineHeaderHeight = (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + - TIMELINE_CONSTANTS.PADDING_TOP_PX || 0; + TIMELINE_CONTENT_TOP_PADDING_PX || 0; return ( <section @@ -483,7 +489,7 @@ export function Timeline() { TRACKS_CONTAINER_HEIGHT.max, getTotalTracksHeight({ tracks }), ), - ) + TIMELINE_CONSTANTS.PADDING_TOP_PX + ) + TIMELINE_CONTENT_TOP_PADDING_PX }px`, }} onMouseDown={(event) => { @@ -577,7 +583,10 @@ function TrackLabelsPanel({ ); return ( - <div className="flex w-28 shrink-0 flex-col border-r"> + <div + className="flex shrink-0 flex-col border-r" + style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }} + > <div className="shrink-0" style={{ height: timelineHeaderHeight || 48 }} @@ -585,21 +594,24 @@ function TrackLabelsPanel({ <div ref={trackLabelsRef} className="flex-1 overflow-hidden"> <div ref={trackLabelsScrollRef} className="size-full overflow-hidden"> {tracks.length > 0 && ( - <div className="flex flex-col" style={{ gap: `${TRACK_GAP}px` }}> + <div + className="flex flex-col" + style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }} + > {tracks.map((track) => ( <div key={track.id} className={cn( "group flex items-center px-3", tracksWithSelection.has(track.id) && - TIMELINE_CONSTANTS.TRACK_SELECTED_BG, + SELECTED_TRACK_ROW_CLASS, )} style={{ height: `${getTrackHeight({ type: track.type })}px`, }} > <div className="flex min-w-0 flex-1 items-center justify-end gap-2"> - {canTracktHaveAudio(track) && ( + {canTrackHaveAudio(track) && ( <TrackToggleIcon isOff={track.muted} icons={{ on: VolumeHighIcon, off: VolumeOffIcon }} @@ -707,10 +719,10 @@ function TimelineTrackRows({ className={cn( "absolute right-0 left-0 transition-colors", tracksWithSelection.has(track.id) && - TIMELINE_CONSTANTS.TRACK_SELECTED_BG, + SELECTED_TRACK_ROW_CLASS, )} style={{ - top: `${TIMELINE_CONSTANTS.PADDING_TOP_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`, + top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`, height: `${getTrackHeight({ type: track.type })}px`, }} > @@ -753,7 +765,7 @@ function TimelineTrackRows({ timeline.toggleTrackMute({ trackId: track.id }); }} > - {canTracktHaveAudio(track) && track.muted + {canTrackHaveAudio(track) && track.muted ? "Unmute track" : "Mute track"} </ContextMenuItem> diff --git a/apps/web/src/components/editor/panels/timeline/interaction.ts b/apps/web/src/components/editor/panels/timeline/interaction.ts new file mode 100644 index 00000000..fe0210a4 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/interaction.ts @@ -0,0 +1,4 @@ +export const TIMELINE_DRAG_THRESHOLD_PX = 5; +export const TIMELINE_HORIZONTAL_WHEEL_STEP_PX = 40; +export const TIMELINE_ZOOM_BUTTON_FACTOR = 1.7; +export const TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD = 0.15; diff --git a/apps/web/src/components/editor/panels/timeline/layers.ts b/apps/web/src/components/editor/panels/timeline/layers.ts new file mode 100644 index 00000000..d59db412 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/layers.ts @@ -0,0 +1,6 @@ +export const TIMELINE_LAYERS = { + trackContent: 10, + dragLine: 20, + playhead: 30, + snapIndicator: 40, +} as const; diff --git a/apps/web/src/components/editor/panels/timeline/layout.ts b/apps/web/src/components/editor/panels/timeline/layout.ts new file mode 100644 index 00000000..88443797 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/layout.ts @@ -0,0 +1,16 @@ +import type { TrackType } from "@/lib/timeline"; + +export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = { + video: 65, + text: 25, + audio: 50, + graphic: 25, + effect: 25, +} as const; + +export const TIMELINE_TRACK_GAP_PX = 6; +export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112; +export const TIMELINE_RULER_HEIGHT_PX = 22; +export const TIMELINE_BOOKMARK_ROW_HEIGHT_PX = 16; +export const TIMELINE_SCROLLBAR_SIZE_PX = 12; +export const TIMELINE_CONTENT_TOP_PADDING_PX = 2; diff --git a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts new file mode 100644 index 00000000..6488e29a --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts @@ -0,0 +1,136 @@ +import type { TimelineTrack } from "@/lib/timeline"; +import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils"; +import { + TIMELINE_CONTENT_TOP_PADDING_PX, +} from "./layout"; +import { getCumulativeHeightBefore, getTrackHeight } from "./track-layout"; + +type TimelineElementRef = { trackId: string; elementId: string }; + +interface SelectionRectangle { + left: number; + top: number; + right: number; + bottom: number; +} + +function getNormalizedRectangle({ + startPos, + endPos, +}: { + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; +}): SelectionRectangle { + return { + left: Math.min(startPos.x, endPos.x), + top: Math.min(startPos.y, endPos.y), + right: Math.max(startPos.x, endPos.x), + bottom: Math.max(startPos.y, endPos.y), + }; +} + +function getSelectionRectangleInContent({ + container, + scrollContainer, + startPos, + endPos, +}: { + container: HTMLElement; + scrollContainer: HTMLDivElement | null; + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; +}): SelectionRectangle { + const containerRect = container.getBoundingClientRect(); + const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + + const adjustedStart = { + x: startPos.x - containerRect.left + scrollLeft, + y: startPos.y - scrollRect.top + scrollTop, + }; + const adjustedEnd = { + x: endPos.x - containerRect.left + scrollLeft, + y: endPos.y - scrollRect.top + scrollTop, + }; + + return getNormalizedRectangle({ + startPos: adjustedStart, + endPos: adjustedEnd, + }); +} + +function isRectangleIntersecting({ + elementRectangle, + selectionRectangle, +}: { + elementRectangle: SelectionRectangle; + selectionRectangle: SelectionRectangle; +}): boolean { + return !( + elementRectangle.right < selectionRectangle.left || + elementRectangle.left > selectionRectangle.right || + elementRectangle.bottom < selectionRectangle.top || + elementRectangle.top > selectionRectangle.bottom + ); +} + +export function resolveTimelineElementIntersections({ + container, + scrollContainer, + tracks, + zoomLevel, + startPos, + currentPos, +}: { + container: HTMLElement; + scrollContainer: HTMLDivElement | null; + tracks: TimelineTrack[]; + zoomLevel: number; + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; +}): TimelineElementRef[] { + const selectionRectangle = getSelectionRectangleInContent({ + container, + scrollContainer, + startPos, + endPos: currentPos, + }); + const pixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel }); + const selectedElements: TimelineElementRef[] = []; + + for (const [trackIndex, track] of tracks.entries()) { + const trackTop = getCumulativeHeightBefore({ + tracks, + trackIndex, + }); + const trackHeight = getTrackHeight({ type: track.type }); + const elementTop = TIMELINE_CONTENT_TOP_PADDING_PX + trackTop; + const elementBottom = elementTop + trackHeight; + + for (const element of track.elements) { + const elementLeft = element.startTime * pixelsPerSecond; + const elementRight = elementLeft + element.duration * pixelsPerSecond; + const elementRectangle = { + left: elementLeft, + top: elementTop, + right: elementRight, + bottom: elementBottom, + }; + + if ( + isRectangleIntersecting({ + elementRectangle, + selectionRectangle, + }) + ) { + selectedElements.push({ + trackId: track.id, + elementId: element.id, + }); + } + } + } + + return selectedElements; +} diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index 766d0843..a86e2a5e 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -6,7 +6,7 @@ import { getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX,
} from "@/lib/timeline";
-import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
+import { TIMELINE_LAYERS } from "./layers";
interface SnapIndicatorProps {
snapPoint: SnapPoint | null;
zoomLevel: number;
diff --git a/apps/web/src/components/editor/panels/timeline/theme.ts b/apps/web/src/components/editor/panels/timeline/theme.ts new file mode 100644 index 00000000..e7fc1f11 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/theme.ts @@ -0,0 +1,29 @@ +import type { TrackType } from "@/lib/timeline"; + +export const TIMELINE_TRACK_THEME: Record< + TrackType, + { + elementClassName: string; + waveformColor?: string; + } +> = { + video: { elementClassName: "transparent" }, + text: { elementClassName: "bg-[#5DBAA0]" }, + audio: { + elementClassName: "bg-[#8F5DBA]", + waveformColor: "rgba(255, 255, 255, 0.5)", + }, + graphic: { elementClassName: "bg-[#BA5D7A]" }, + effect: { elementClassName: "bg-[#5d93ba]" }, +} as const; + +export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50"; +export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff"; + +export function getTimelineElementClassName({ + type, +}: { + type: TrackType; +}): string { + return TIMELINE_TRACK_THEME[type].elementClassName.trim(); +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index bb30b0b0..25218d78 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -12,8 +12,6 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { getElementKeyframes } from "@/lib/animation";
import {
- getElementClasses,
- getTrackHeight,
canElementHaveAudio,
canElementBeHidden,
hasElementEffects,
@@ -21,7 +19,11 @@ import { timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
-import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
+import { getTrackHeight } from "./track-layout";
+import {
+ getTimelineElementClassName,
+ TIMELINE_TRACK_THEME,
+} from "./theme";
import {
ContextMenu,
ContextMenuContent,
@@ -464,7 +466,7 @@ function ElementInner({ <div
className={cn(
"absolute inset-0 overflow-hidden rounded-sm",
- getElementClasses({ type: track.type }),
+ getTimelineElementClassName({ type: track.type }),
isReducedOpacity && "opacity-50",
)}
>
@@ -710,7 +712,7 @@ function AudioElementContent({ element }: { element: AudioElement }) { <AudioWaveform
audioBuffer={audioBuffer}
audioUrl={audioUrl}
- color={ELEMENT_TYPE_CONFIG.audio.waveformColor}
+ color={TIMELINE_TRACK_THEME.audio.waveformColor}
/>
<MediaElementHeader name={mediaLabel} hasFade={false} />
</div>
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index 1abe65bd..361f1398 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -9,9 +9,9 @@ import { import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; import { useEditor } from "@/hooks/use-editor"; import { - TIMELINE_LAYERS, TIMELINE_SCROLLBAR_SIZE_PX, -} from "@/constants/timeline-constants"; +} from "./layout"; +import { TIMELINE_LAYERS } from "./layers"; interface TimelinePlayheadProps { zoomLevel: number; diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx index 09562f23..9e49bf6c 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx @@ -1,9 +1,9 @@ import { type JSX, useLayoutEffect, useRef } from "react"; import { - TIMELINE_CONSTANTS, - TIMELINE_RULER_HEIGHT, -} from "@/constants/timeline-constants"; -import { DEFAULT_FPS } from "@/constants/project-constants"; + BASE_TIMELINE_PIXELS_PER_SECOND, +} from "@/lib/timeline/scale"; +import { TIMELINE_RULER_HEIGHT_PX } from "./layout"; +import { DEFAULT_FPS } from "@/lib/fps/constants"; import { useEditor } from "@/hooks/use-editor"; import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; import { useScrollPosition } from "@/hooks/timeline/use-scroll-position"; @@ -31,7 +31,7 @@ export function TimelineRuler({ handleRulerMouseDown, }: TimelineRulerProps) { const duration = useEditor((e) => e.timeline.getTotalDuration()); - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; const visibleDuration = dynamicTimelineWidth / pixelsPerSecond; const effectiveDuration = Math.max(duration, visibleDuration); const fps = @@ -107,7 +107,7 @@ export function TimelineRuler({ aria-valuemax={effectiveDuration} aria-valuenow={0} className="relative flex-1 overflow-x-visible" - style={{ height: TIMELINE_RULER_HEIGHT }} + style={{ height: TIMELINE_RULER_HEIGHT_PX }} onWheel={handleWheel} onClick={(event) => { // Ruler seek already happens on mousedown via playhead scrubbing. @@ -124,7 +124,7 @@ export function TimelineRuler({ ref={rulerRef} className="relative cursor-default select-none" style={{ - height: TIMELINE_RULER_HEIGHT, + height: TIMELINE_RULER_HEIGHT_PX, width: `${dynamicTimelineWidth}px`, }} onMouseDown={handleRulerMouseDown} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index ff43e00a..ecaf37e2 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -14,7 +14,10 @@ import { SplitButtonSeparator, } from "@/components/ui/split-button"; import { Slider } from "@/components/ui/slider"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { + TIMELINE_ZOOM_BUTTON_FACTOR, +} from "./interaction"; +import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; import { ScenesView } from "@/components/editor/scenes-view"; import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions"; @@ -60,10 +63,10 @@ export function TimelineToolbar({ const newZoomLevel = direction === "in" ? Math.min( - TIMELINE_CONSTANTS.ZOOM_MAX, - zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR, + TIMELINE_ZOOM_MAX, + zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR, ) - : Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR); + : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR); setZoomLevel({ zoom: newZoomLevel }); }; diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index ffc2007b..888e1496 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -5,7 +5,8 @@ import { TimelineElement } from "./timeline-element"; import type { TimelineTrack } from "@/lib/timeline";
import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
+import { TIMELINE_LAYERS } from "./layers";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
@@ -59,7 +60,7 @@ export function TimelineTrackContent({ getMouseClientX: () => lastMouseXRef.current ?? 0,
rulerScrollRef,
tracksScrollRef,
- contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
});
return (
diff --git a/apps/web/src/components/editor/panels/timeline/track-layout.ts b/apps/web/src/components/editor/panels/timeline/track-layout.ts new file mode 100644 index 00000000..89cc4d05 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/track-layout.ts @@ -0,0 +1,37 @@ +import type { TrackType } from "@/lib/timeline"; +import { + TIMELINE_TRACK_GAP_PX, + TIMELINE_TRACK_HEIGHTS_PX, +} from "./layout"; + +export function getTrackHeight({ type }: { type: TrackType }): number { + return TIMELINE_TRACK_HEIGHTS_PX[type]; +} + +export function getCumulativeHeightBefore({ + tracks, + trackIndex, +}: { + tracks: Array<{ type: TrackType }>; + trackIndex: number; +}): number { + return tracks + .slice(0, trackIndex) + .reduce( + (sum, track) => sum + getTrackHeight({ type: track.type }) + TIMELINE_TRACK_GAP_PX, + 0, + ); +} + +export function getTotalTracksHeight({ + tracks, +}: { + tracks: Array<{ type: TrackType }>; +}): number { + const tracksHeight = tracks.reduce( + (sum, track) => sum + getTrackHeight({ type: track.type }), + 0, + ); + const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX; + return tracksHeight + gapsHeight; +} |
