we're getting there

This commit is contained in:
Maze Winther
2026-01-18 08:46:14 +01:00
parent 981ac5e237
commit d260212b12
14 changed files with 203 additions and 1119 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -79,6 +79,7 @@ export function Timeline() {
const {
dragState,
dragDropTarget,
handleElementMouseDown,
handleElementClick,
lastMouseXRef,
@@ -312,6 +313,11 @@ export function Timeline() {
tracks={timeline.getTracks()}
isVisible={isDragOver}
/>
<DragLine
dropTarget={dragDropTarget}
tracks={timeline.getTracks()}
isVisible={dragState.isDragging}
/>
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
<div
@@ -96,6 +96,10 @@ export function TimelineElement({
);
const isBeingDragged = dragState.elementId === element.id;
const dragOffsetY =
isBeingDragged && dragState.isDragging
? dragState.currentMouseY - dragState.startMouseY
: 0;
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
@@ -130,9 +134,16 @@ export function TimelineElement({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`timeline-element absolute top-0 h-full select-none ${isBeingDragged ? "z-30" : "z-10"
className={`absolute top-0 h-full select-none ${isBeingDragged ? "z-30" : "z-10"
}`}
style={{ left: `${elementLeft}px`, width: `${elementWidth}px` }}
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
transform:
isBeingDragged && dragState.isDragging
? `translate3d(0, ${dragOffsetY}px, 0)`
: undefined,
}}
data-element-id={element.id}
data-track-id={track.id}
>
@@ -53,10 +53,10 @@ export function TimelineTrackContent({
return (
<div
className="hover:bg-muted/20 size-full"
className="size-full"
onClick={clearElementSelection}
>
<div className="track-elements-container relative h-full min-w-full">
<div className="relative h-full min-w-full">
{track.elements.length === 0 ? (
<div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
) : (
@@ -13,6 +13,7 @@ import { snapTimeToFrame } from "@/lib/time-utils";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { generateUUID } from "@/lib/utils";
import type {
DropTarget,
ElementDragState,
TimelineElement,
TimelineTrack,
@@ -38,6 +39,7 @@ const initialDragState: ElementDragState = {
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
currentMouseY: 0,
};
interface PendingDragState {
@@ -84,9 +86,60 @@ function getElementDuration({ element }: { element: TimelineElement }): number {
return element.duration - element.trimStart - element.trimEnd;
}
function getDragDropTarget({
clientX,
clientY,
elementId,
trackId,
tracks,
tracksContainerRef,
tracksScrollRef,
zoomLevel,
snappedTime,
}: {
clientX: number;
clientY: number;
elementId: string;
trackId: string;
tracks: TimelineTrack[];
tracksContainerRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
snappedTime: number;
}): DropTarget | null {
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
const scrollContainer = tracksScrollRef.current;
if (!containerRect || !scrollContainer) return null;
const sourceTrack = tracks.find(({ id }) => id === trackId);
const movingElement = sourceTrack?.elements.find(({ id }) => id === elementId);
if (!movingElement) return null;
const elementDuration = getElementDuration({ element: movingElement });
const scrollLeft = scrollContainer.scrollLeft;
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const mouseX = clientX - scrollContainerRect.left + scrollLeft;
const mouseY = clientY - containerRect.top;
return computeDropTarget({
elementType: movingElement.type,
mouseX,
mouseY,
tracks,
playheadTime: snappedTime,
isExternalDrop: false,
elementDuration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
startTimeOverride: snappedTime,
excludeElementId: movingElement.id,
});
}
interface StartDragParams
extends Omit<ElementDragState, "isDragging" | "currentTime"> {
extends Omit<ElementDragState, "isDragging" | "currentTime" | "currentMouseY"> {
initialCurrentTime: number;
initialCurrentMouseY: number;
}
export function useElementInteraction({
@@ -106,6 +159,7 @@ export function useElementInteraction({
const [dragState, setDragState] =
useState<ElementDragState>(initialDragState);
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingDragState | null>(null);
const lastMouseXRef = useRef(0);
@@ -120,6 +174,7 @@ export function useElementInteraction({
startElementTime,
clickOffsetTime,
initialCurrentTime,
initialCurrentMouseY,
}: StartDragParams) => {
setDragState({
isDragging: true,
@@ -130,6 +185,7 @@ export function useElementInteraction({
startElementTime,
clickOffsetTime,
currentTime: initialCurrentTime,
currentMouseY: initialCurrentMouseY,
});
},
[],
@@ -137,6 +193,7 @@ export function useElementInteraction({
const endDrag = useCallback(() => {
setDragState(initialDragState);
setDragDropTarget(null);
}, []);
useEffect(() => {
@@ -173,6 +230,7 @@ export function useElementInteraction({
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
initialCurrentMouseY: clientY,
});
startedDragThisEvent = true;
pendingDragRef.current = null;
@@ -215,7 +273,23 @@ export function useElementInteraction({
setDragState((previousDragState) => ({
...previousDragState,
currentTime: snappedTime,
currentMouseY: clientY,
}));
if (dragState.elementId && dragState.trackId) {
const dropTarget = getDragDropTarget({
clientX,
clientY,
elementId: dragState.elementId,
trackId: dragState.trackId,
tracks,
tracksContainerRef,
tracksScrollRef,
zoomLevel,
snappedTime,
});
setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null);
}
};
document.addEventListener("mousemove", handleMouseMove);
@@ -231,6 +305,8 @@ export function useElementInteraction({
editor.project,
timelineRef,
tracksScrollRef,
tracksContainerRef,
tracks,
isPendingDrag,
startDrag,
]);
@@ -241,37 +317,6 @@ export function useElementInteraction({
const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
if (!dragState.elementId || !dragState.trackId) return;
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
if (!containerRect) {
endDrag();
onSnapPointChange?.(null);
return;
}
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
if (!sourceTrack) {
endDrag();
onSnapPointChange?.(null);
return;
}
const movingElement = sourceTrack?.elements.find(
({ id }) => id === dragState.elementId,
);
if (!movingElement) {
endDrag();
onSnapPointChange?.(null);
return;
}
const elementDuration = getElementDuration({ element: movingElement });
const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
const scrollContainerRect =
tracksScrollRef.current?.getBoundingClientRect();
const mouseX = scrollContainerRect
? clientX - scrollContainerRect.left + scrollLeft
: clientX - containerRect.left + scrollLeft;
const mouseY = clientY - containerRect.top;
if (mouseDownLocationRef.current) {
const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
@@ -283,23 +328,34 @@ export function useElementInteraction({
}
}
const dropTarget = computeDropTarget({
elementType: movingElement.type,
mouseX,
mouseY,
const dropTarget = getDragDropTarget({
clientX,
clientY,
elementId: dragState.elementId,
trackId: dragState.trackId,
tracks,
playheadTime: dragState.currentTime,
isExternalDrop: false,
elementDuration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
tracksContainerRef,
tracksScrollRef,
zoomLevel,
excludeElementId: movingElement.id,
snappedTime: dragState.currentTime,
});
if (!dropTarget) {
endDrag();
onSnapPointChange?.(null);
return;
}
const snappedTime = dragState.currentTime;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
if (!sourceTrack) {
endDrag();
onSnapPointChange?.(null);
return;
}
if (dropTarget.isNewTrack) {
const newTrackId = generateUUID();
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
targetTrackId: newTrackId,
@@ -453,6 +509,7 @@ export function useElementInteraction({
return {
dragState,
dragDropTarget,
handleElementMouseDown,
handleElementClick,
lastMouseXRef,
@@ -3,6 +3,7 @@ import { EditorCore } from "@/core";
import type { TimelineTrack, TimelineElement, TrackType } from "@/types/timeline";
import {
buildEmptyTrack,
isMainTrack,
validateElementTrackCompatibility,
} from "@/lib/timeline/track-utils";
@@ -64,7 +65,7 @@ export class MoveElementCommand extends Command {
const isSameTrack = this.sourceTrackId === this.targetTrackId;
const updatedTracks = tracksToUpdate.map((track) => {
let updatedTracks = tracksToUpdate.map((track) => {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
@@ -91,6 +92,21 @@ export class MoveElementCommand extends Command {
return track;
}) as TimelineTrack[];
if (!isSameTrack) {
const sourceTrackAfterMove = updatedTracks.find(
(track) => track.id === this.sourceTrackId,
);
if (
sourceTrackAfterMove &&
sourceTrackAfterMove.elements.length === 0 &&
!isMainTrack(sourceTrackAfterMove)
) {
updatedTracks = updatedTracks.filter(
(track) => track.id !== this.sourceTrackId,
);
}
}
editor.timeline.updateTracks(updatedTracks);
}
-11
View File
@@ -57,14 +57,3 @@ export const verifications = pgTable("verifications", {
() => /* @__PURE__ */ new Date()
),
}).enableRLS();
export const exportWaitlist = pgTable("export_waitlist", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
createdAt: timestamp("created_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
updatedAt: timestamp("updated_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
}).enableRLS();
-15
View File
@@ -1,15 +0,0 @@
import { z } from "zod";
export const exportWaitlistSchema = z.object({
email: z.string().email().max(320),
});
export const exportWaitlistResponseSchema = z.object({
success: z.boolean(),
alreadySubscribed: z.boolean().optional(),
});
export type ExportWaitlistInput = z.infer<typeof exportWaitlistSchema>;
export type ExportWaitlistResponse = z.infer<
typeof exportWaitlistResponseSchema
>;
+12 -6
View File
@@ -64,7 +64,7 @@ function findInsertIndex({
}): { index: number; position: "above" | "below" } {
const mainTrackIndex = getMainTrackIndex({ tracks });
if (elementType === "audio") {
if (elementType === "audio") {
if (preferredIndex <= mainTrackIndex) {
return { index: mainTrackIndex + 1, position: "below" };
}
@@ -74,12 +74,14 @@ function findInsertIndex({
};
}
if (preferredIndex > mainTrackIndex && mainTrackIndex >= 0) {
const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
return { index: mainTrackIndex, position: "above" };
}
return {
index: insertAbove ? preferredIndex : preferredIndex + 1,
index: overlayInsertIndex,
position: insertAbove ? "above" : "below",
};
}
@@ -94,11 +96,15 @@ export function computeDropTarget({
elementDuration,
pixelsPerSecond,
zoomLevel,
startTimeOverride,
excludeElementId,
}: ComputeDropTargetParams): DropTarget {
const xPosition = isExternalDrop
? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
const xPosition =
typeof startTimeOverride === "number"
? startTimeOverride
: isExternalDrop
? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
const mainTrackIndex = getMainTrackIndex({ tracks });
+2
View File
@@ -158,6 +158,7 @@ export interface ElementDragState {
startElementTime: number;
clickOffsetTime: number;
currentTime: number;
currentMouseY: number;
}
export interface DropTarget {
@@ -177,6 +178,7 @@ export interface ComputeDropTargetParams {
elementDuration: number;
pixelsPerSecond: number;
zoomLevel: number;
startTimeOverride?: number;
excludeElementId?: string;
}
+17 -6
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,7 +23,9 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
},
"forceConsistentCasingInFileNames": true
},
@@ -29,7 +35,12 @@
"apps/web/.next/types/**/*.ts",
"next-env.d.ts",
".next/types/**/*.ts",
"src/types/**/*.d.ts"
, "../../use-frame-cache.ts", "src/stores/timeline-store.ts" ],
"exclude": ["node_modules"]
"src/types/**/*.d.ts",
"../../use-frame-cache.ts",
"src/stores/timeline-store.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}