mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: split constants by domain and isolate timeline panel from core
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
export const TIMELINE_LAYERS = {
|
||||
trackContent: 10,
|
||||
dragLine: 20,
|
||||
playhead: 30,
|
||||
snapIndicator: 40,
|
||||
} as const;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user