feat: major editor overhaul (assets, properties, timeline, fonts) (#709)

* feat: major editor overhaul (assets, properties, timeline, fonts)

Refactor editor core systems to standardize UI architecture and improve performance.

Assets & Properties:
- Replace monolithic property items with composable `Section` architecture.
- Add specialized sections for Transform, Blending, and Text.
- Implement `NumberField` with scrubbing and math evaluation.
- Add new ColorPicker with EyeDropper and multiple format support.
- Standardize asset panels using new `PanelView` layout.

Fonts & Stickers:
- Implement custom font atlas/sprite system for high-performance previews.
- Add virtualized FontPicker with search and favorites.
- Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes).
- Standardize sticker IDs to `provider:value` format.

Timeline & Interaction:
- Convert bookmarks to rich objects with notes, colors, and duration.
- Refactor drag-and-drop to use Command pattern (enabling proper undo/redo).
- Add Shift modifier to disable snapping during moves/resizes.
- Add new overlays for layout guides and text editing.

Renderer:
- Add support for multi-line text, custom line-height, and letter-spacing.
- Implement global composite operation (blend modes).
- Update sticker node to resolve dynamic provider IDs.

Infrastructure:
- Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks.
- Update global styles and core UI components (Button, Input, Popover).

* add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors

* fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling

* deleted shadcn components with errors

* formatting

* fix linter issues

* migrate from next middleware to proxy

* add missing component back

* add breadcrumb back

* chore: add @radix-ui/react-primitive deps

* chore: more deps

* chore: add missing env vars to bun-ci

* next env
This commit is contained in:
Maze
2026-02-23 03:24:02 +01:00
committed by GitHub
parent fca99d6126
commit 93d1e3383c
215 changed files with 26980 additions and 8364 deletions
@@ -7,10 +7,15 @@ import {
type RefObject,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
DRAG_THRESHOLD_PX,
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
import type {
@@ -21,8 +26,6 @@ import type {
} from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
const DRAG_THRESHOLD_PX = 5;
interface UseElementInteractionProps {
zoomLevel: number;
timelineRef: RefObject<HTMLDivElement | null>;
@@ -54,24 +57,6 @@ interface PendingDragState {
clickOffsetTime: number;
}
function getMouseTimeFromClientX({
clientX,
containerRect,
zoomLevel,
scrollLeft,
}: {
clientX: number;
containerRect: DOMRect;
zoomLevel: number;
scrollLeft: number;
}): number {
const mouseX = clientX - containerRect.left + scrollLeft;
return Math.max(
0,
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
);
}
function getClickOffsetTime({
clientX,
elementRect,
@@ -175,6 +160,7 @@ export function useElementInteraction({
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const { snapElementEdge } = useTimelineSnapping();
const {
@@ -230,7 +216,8 @@ export function useElementInteraction({
frameSnappedTime: number;
movingElement: TimelineElement | null | undefined;
}) => {
if (!snappingEnabled || !movingElement) {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (!shouldSnap || !movingElement) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
@@ -268,7 +255,14 @@ export function useElementInteraction({
snapPoint: snapResult.snapPoint,
};
},
[snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel],
[
snappingEnabled,
editor.playback,
snapElementEdge,
tracks,
zoomLevel,
isShiftHeldRef,
],
);
useEffect(() => {
@@ -1,7 +1,9 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { EditorCore } from "@/core";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
useTimelineSnapping,
type SnapPoint,
@@ -33,8 +35,9 @@ export function useTimelineElementResize({
onSnapPointChange,
onResizeStateChange,
}: UseTimelineElementResizeProps) {
const editor = EditorCore.getInstance();
const editor = useEditor();
const activeProject = editor.project.getActive();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
@@ -49,21 +52,21 @@ export function useTimelineElementResize({
const currentDurationRef = useRef(element.duration);
const handleResizeStart = ({
e,
event,
elementId,
side,
}: {
e: React.MouseEvent;
event: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => {
e.stopPropagation();
e.preventDefault();
event.stopPropagation();
event.preventDefault();
setResizing({
elementId,
side,
startX: e.clientX,
startX: event.clientX,
initialTrimStart: element.trimStart,
initialTrimEnd: element.trimEnd,
initialStartTime: element.startTime,
@@ -89,104 +92,69 @@ export function useTimelineElementResize({
return false;
}, [element.type]);
const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => {
if (!resizing) return;
const updateTrimFromMouseMove = useCallback(
({ clientX }: { clientX: number }) => {
if (!resizing) return;
const deltaX = clientX - resizing.startX;
let deltaTime = deltaX / (50 * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const deltaX = clientX - resizing.startX;
let deltaTime =
deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = activeProject.settings.fps;
const minDurationSeconds = 1 / projectFps;
const canSnap = snappingEnabled;
if (canSnap) {
const tracks = editor.timeline.getTracks();
const playheadTime = editor.playback.getCurrentTime();
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId: element.id,
});
if (resizing.side === "left") {
const targetStartTime = resizing.initialStartTime + deltaTime;
const snapResult = snapToNearestPoint({
targetTime: targetStartTime,
snapPoints,
zoomLevel,
const projectFps = activeProject.settings.fps;
const minDurationSeconds = 1 / projectFps;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) {
const tracks = editor.timeline.getTracks();
const playheadTime = editor.playback.getCurrentTime();
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId: element.id,
});
resizeSnapPoint = snapResult.snapPoint;
if (snapResult.snapPoint) {
deltaTime = snapResult.snappedTime - resizing.initialStartTime;
}
} else {
const baseEndTime =
resizing.initialStartTime + resizing.initialDuration;
const targetEndTime = baseEndTime + deltaTime;
const snapResult = snapToNearestPoint({
targetTime: targetEndTime,
snapPoints,
zoomLevel,
});
resizeSnapPoint = snapResult.snapPoint;
if (snapResult.snapPoint) {
deltaTime = snapResult.snappedTime - baseEndTime;
if (resizing.side === "left") {
const targetStartTime = resizing.initialStartTime + deltaTime;
const snapResult = snapToNearestPoint({
targetTime: targetStartTime,
snapPoints,
zoomLevel,
});
resizeSnapPoint = snapResult.snapPoint;
if (snapResult.snapPoint) {
deltaTime = snapResult.snappedTime - resizing.initialStartTime;
}
} else {
const baseEndTime =
resizing.initialStartTime + resizing.initialDuration;
const targetEndTime = baseEndTime + deltaTime;
const snapResult = snapToNearestPoint({
targetTime: targetEndTime,
snapPoints,
zoomLevel,
});
resizeSnapPoint = snapResult.snapPoint;
if (snapResult.snapPoint) {
deltaTime = snapResult.snappedTime - baseEndTime;
}
}
}
}
onSnapPointChange?.(resizeSnapPoint);
onSnapPointChange?.(resizeSnapPoint);
if (resizing.side === "left") {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const maxAllowed =
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
const calculated = resizing.initialTrimStart + deltaTime;
if (resizing.side === "left") {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const maxAllowed =
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
time: Math.min(maxAllowed, calculated),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else if (calculated < 0) {
if (canExtendElementDuration()) {
const extensionAmount = Math.abs(calculated);
const maxExtension = resizing.initialStartTime;
const actualExtension = Math.min(extensionAmount, maxExtension);
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
time: Math.min(maxAllowed, calculated),
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + actualExtension,
fps: projectFps,
});
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
const trimDelta = 0 - resizing.initialTrimStart;
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
@@ -196,68 +164,119 @@ export function useTimelineElementResize({
fps: projectFps,
});
setCurrentTrimStart(0);
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
}
} else {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
} else if (calculated < 0) {
if (canExtendElementDuration()) {
const extensionAmount = Math.abs(calculated);
const maxExtension = resizing.initialStartTime;
const actualExtension = Math.min(extensionAmount, maxExtension);
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + actualExtension,
fps: projectFps,
});
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: baseDuration + extensionNeeded,
fps: projectFps,
});
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
const trimDelta = 0 - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
} else {
const extensionToLimit = resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionToLimit,
fps: projectFps,
});
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
currentTrimStartRef.current = 0;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
}
} else {
const maxTrimEnd =
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
});
const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
currentTrimEndRef.current = finalTrimEnd;
currentDurationRef.current = newDuration;
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: baseDuration + extensionNeeded,
fps: projectFps,
});
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
} else {
const extensionToLimit = resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionToLimit,
fps: projectFps,
});
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
}
} else {
const maxTrimEnd =
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
});
const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
currentTrimEndRef.current = finalTrimEnd;
currentDurationRef.current = newDuration;
}
}
}
}, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]);
},
[
resizing,
zoomLevel,
activeProject.settings.fps,
snappingEnabled,
editor,
findSnapPoints,
snapToNearestPoint,
element.id,
onSnapPointChange,
canExtendElementDuration,
isShiftHeldRef,
],
);
const handleResizeEnd = useCallback(() => {
if (!resizing) return;
@@ -297,7 +316,14 @@ export function useTimelineElementResize({
setResizing(null);
onResizeStateChange?.({ isResizing: false });
onSnapPointChange?.(null);
}, [resizing, editor.timeline, element.id, track.id, onResizeStateChange, onSnapPointChange]);
}, [
resizing,
editor.timeline,
element.id,
track.id,
onResizeStateChange,
onSnapPointChange,
]);
useEffect(() => {
if (!resizing) return;
@@ -21,7 +21,9 @@ export function useElementSelection() {
const selectElement = useCallback(
({ trackId, elementId }: ElementRef) => {
editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] });
editor.selection.setSelectedElements({
elements: [{ trackId, elementId }],
});
},
[editor],
);
@@ -0,0 +1,289 @@
import {
useState,
useCallback,
useEffect,
useRef,
type RefObject,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
import type { Bookmark } from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
export interface BookmarkDragState {
isDragging: boolean;
bookmarkTime: number | null;
currentTime: number;
}
interface PendingBookmarkDrag {
bookmarkTime: number;
startMouseX: number;
startMouseY: number;
}
interface UseBookmarkDragProps {
zoomLevel: number;
scrollRef: RefObject<HTMLElement | null>;
snappingEnabled: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
export function useBookmarkDrag({
zoomLevel,
scrollRef,
snappingEnabled,
onSnapPointChange,
}: UseBookmarkDragProps) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const activeScene = editor.scenes.getActiveScene();
const bookmarks = activeScene?.bookmarks ?? [];
const playheadTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration();
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
const [dragState, setDragState] = useState<BookmarkDragState>({
isDragging: false,
bookmarkTime: null,
currentTime: 0,
});
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
const lastMouseXRef = useRef(0);
const startDrag = useCallback(
({
bookmarkTime,
initialCurrentTime,
}: {
bookmarkTime: number;
initialCurrentTime: number;
}) => {
setDragState({
isDragging: true,
bookmarkTime,
currentTime: initialCurrentTime,
});
},
[],
);
const endDrag = useCallback(() => {
setDragState({
isDragging: false,
bookmarkTime: null,
currentTime: 0,
});
}, []);
const getSnapResult = useCallback(
({
rawTime,
excludeBookmarkTime,
}: {
rawTime: number;
excludeBookmarkTime: number;
}): { snappedTime: number; snapPoint: SnapPoint | null } => {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (!shouldSnap) {
return { snappedTime: rawTime, snapPoint: null };
}
const snapPoints = findSnapPoints({
tracks,
playheadTime,
bookmarks,
excludeBookmarkTime,
});
const result = snapToNearestPoint({
targetTime: rawTime,
snapPoints,
zoomLevel,
});
return {
snappedTime: result.snappedTime,
snapPoint: result.snapPoint,
};
},
[
snappingEnabled,
findSnapPoints,
snapToNearestPoint,
tracks,
playheadTime,
bookmarks,
zoomLevel,
isShiftHeldRef,
],
);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
const handleMouseMove = (event: MouseEvent) => {
lastMouseXRef.current = event.clientX;
const scrollContainer = scrollRef.current;
if (!scrollContainer) return;
if (isPendingDrag && pendingDragRef.current) {
const { startMouseX, startMouseY, bookmarkTime } =
pendingDragRef.current;
const deltaX = Math.abs(event.clientX - startMouseX);
const deltaY = Math.abs(event.clientY - startMouseY);
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
return;
}
const activeProject = editor.project.getActive();
if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft;
const mouseTime = getMouseTimeFromClientX({
clientX: event.clientX,
containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel,
scrollLeft,
});
const frameSnappedTime = snapTimeToFrame({
time: Math.max(0, Math.min(mouseTime, duration)),
fps: activeProject.settings.fps,
});
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
});
startDrag({
bookmarkTime,
initialCurrentTime: initialTime,
});
pendingDragRef.current = null;
setIsPendingDrag(false);
return;
}
if (!dragState.isDragging || dragState.bookmarkTime === null) return;
const activeProject = editor.project.getActive();
if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft;
const mouseTime = getMouseTimeFromClientX({
clientX: event.clientX,
containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel,
scrollLeft,
});
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
const frameSnappedTime = snapTimeToFrame({
time: clampedTime,
fps: activeProject.settings.fps,
});
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
});
setDragState((previousDragState) => ({
...previousDragState,
currentTime: snapResult.snappedTime,
}));
onSnapPointChange?.(snapResult.snapPoint);
};
document.addEventListener("mousemove", handleMouseMove);
return () => document.removeEventListener("mousemove", handleMouseMove);
}, [
dragState.isDragging,
dragState.bookmarkTime,
zoomLevel,
duration,
editor.project,
scrollRef,
isPendingDrag,
startDrag,
getSnapResult,
onSnapPointChange,
]);
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseUp = () => {
if (dragState.bookmarkTime === null) {
endDrag();
onSnapPointChange?.(null);
return;
}
const clampedTime = Math.max(
0,
Math.min(dragState.currentTime, duration),
);
editor.scenes.moveBookmark({
fromTime: dragState.bookmarkTime,
toTime: clampedTime,
});
endDrag();
onSnapPointChange?.(null);
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [
dragState.isDragging,
dragState.bookmarkTime,
dragState.currentTime,
duration,
endDrag,
onSnapPointChange,
editor.scenes,
]);
useEffect(() => {
if (!isPendingDrag) return;
const handleMouseUp = () => {
pendingDragRef.current = null;
setIsPendingDrag(false);
onSnapPointChange?.(null);
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [isPendingDrag, onSnapPointChange]);
const handleBookmarkMouseDown = useCallback(
({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
pendingDragRef.current = {
bookmarkTime: bookmark.time,
startMouseX: event.clientX,
startMouseY: event.clientY,
};
setIsPendingDrag(true);
},
[],
);
return {
dragState,
handleBookmarkMouseDown,
lastMouseXRef,
};
}
@@ -7,10 +7,12 @@ import { snapTimeToFrame } from "@/lib/time";
import {
buildTextElement,
buildStickerElement,
buildUploadAudioElement,
buildVideoElement,
buildImageElement,
buildElementFromMedia,
} from "@/lib/timeline/element-utils";
import type { Command } from "@/lib/commands/base-command";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data";
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
@@ -231,7 +233,8 @@ export function useTimelineDragDrop({
}
const element = buildStickerElement({
iconName: dragData.iconName,
stickerId: dragData.stickerId,
name: dragData.name,
startTime: target.xPosition,
});
@@ -265,38 +268,18 @@ export function useTimelineDragDrop({
const duration =
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: mediaAsset.id,
mediaType: mediaAsset.type,
name: mediaAsset.name,
duration,
startTime: target.xPosition,
});
if (dragData.mediaType === "audio") {
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: buildUploadAudioElement({
mediaId: mediaAsset.id,
name: mediaAsset.name,
duration,
startTime: target.xPosition,
}),
});
} else if (dragData.mediaType === "video") {
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: buildVideoElement({
mediaId: mediaAsset.id,
name: mediaAsset.name,
duration,
startTime: target.xPosition,
}),
});
} else {
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: buildImageElement({
mediaId: mediaAsset.id,
name: mediaAsset.name,
duration,
startTime: target.xPosition,
}),
});
}
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
},
[editor.timeline, mediaAssets, tracks],
);
@@ -314,80 +297,67 @@ export function useTimelineDragDrop({
if (!activeProject) return;
const processedAssets = await processMediaAssets({ files });
const projectId = activeProject.metadata.id;
for (const asset of processedAssets) {
await editor.media.addMediaAsset({
projectId: activeProject.metadata.id,
asset,
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks();
const dropTarget = computeDropTarget({
elementType: asset.type,
mouseX,
mouseY,
tracks: currentTracks,
playheadTime: currentTime,
isExternalDrop: true,
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
});
const added = editor.media
.getAssets()
.find((m) => m.name === asset.name && m.url === asset.url);
const trackType: TrackType = asset.type === "audio" ? "audio" : "video";
const addMediaCmd = new AddMediaAssetCommand(projectId, asset);
const assetId = addMediaCmd.getAssetId();
if (added) {
const duration =
added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks();
const dropTarget = computeDropTarget({
elementType: added.type,
mouseX,
mouseY,
tracks: currentTracks,
playheadTime: currentTime,
isExternalDrop: true,
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
});
const commands: Command[] = [addMediaCmd];
const trackType: TrackType =
added.type === "audio" ? "audio" : "video";
const trackId = dropTarget.isNewTrack
? editor.timeline.addTrack({
type: trackType,
index: dropTarget.trackIndex,
})
: currentTracks[dropTarget.trackIndex]?.id;
if (!trackId) return;
if (added.type === "audio") {
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: buildUploadAudioElement({
mediaId: added.id,
name: added.name,
duration,
startTime: dropTarget.xPosition,
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
}),
});
} else if (added.type === "video") {
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: buildVideoElement({
mediaId: added.id,
name: added.name,
duration,
startTime: dropTarget.xPosition,
}),
});
} else {
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: buildImageElement({
mediaId: added.id,
name: added.name,
duration,
startTime: dropTarget.xPosition,
}),
});
}
let trackId: string | undefined;
if (dropTarget.isNewTrack) {
const addTrackCmd = new AddTrackCommand(
trackType,
dropTarget.trackIndex,
);
trackId = addTrackCmd.getTrackId();
commands.unshift(addTrackCmd);
} else {
trackId = currentTracks[dropTarget.trackIndex]?.id;
}
if (!trackId) return;
const element = buildElementFromMedia({
mediaId: assetId,
mediaType: asset.type,
name: asset.name,
duration,
startTime: dropTarget.xPosition,
buffer:
asset.type === "audio"
? new AudioBuffer({ length: 1, sampleRate: 44100 })
: undefined,
});
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "explicit", trackId },
});
commands.push(insertCmd);
const batchCmd = new BatchCommand(commands);
editor.command.execute({ command: batchCmd });
}
},
[activeProject, editor.media, editor.timeline, currentTime, zoomLevel],
[activeProject, editor.command, editor.timeline, currentTime, zoomLevel],
);
const handleDrop = useCallback(
@@ -2,6 +2,11 @@ import { getSnappedSeekTime } from "@/lib/time";
import { useState, useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
useTimelineSnapping,
type SnapPoint,
} from "@/hooks/timeline/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
@@ -25,6 +30,11 @@ export function useTimelinePlayhead({
const duration = editor.timeline.getTotalDuration();
const isPlaying = editor.playback.getIsPlaying();
const isScrubbing = editor.playback.getIsScrubbing();
const isShiftHeldRef = useShiftKey();
const { snapToNearestPoint } = useTimelineSnapping({
enableElementSnapping: false,
enablePlayheadSnapping: false,
});
const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }),
@@ -62,19 +72,45 @@ export function useTimelinePlayhead({
clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
),
);
const framesPerSecond = activeProject.settings.fps;
const time = getSnappedSeekTime({
const frameTime = getSnappedSeekTime({
rawTime,
duration,
fps: framesPerSecond,
});
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
const bookmarkSnapPoints: SnapPoint[] = bookmarks.map((bookmark) => ({
time: bookmark.time,
type: "bookmark",
}));
const shouldSnapToBookmark =
!isShiftHeldRef.current && bookmarkSnapPoints.length > 0;
const snapResult = shouldSnapToBookmark
? snapToNearestPoint({
targetTime: frameTime,
snapPoints: bookmarkSnapPoints,
zoomLevel,
})
: null;
const time = snapResult?.snapPoint ? snapResult.snappedTime : frameTime;
setScrubTime(time);
seek({ time });
lastMouseXRef.current = event.clientX;
},
[duration, zoomLevel, seek, rulerRef, activeProject.settings.fps],
[
duration,
zoomLevel,
seek,
rulerRef,
activeProject.settings.fps,
isShiftHeldRef,
editor.scenes,
snapToNearestPoint,
],
);
const handlePlayheadMouseDown = useCallback(
@@ -1,10 +1,11 @@
import { useCallback } from "react";
import type { TimelineTrack } from "@/types/timeline";
import type { Bookmark, TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
export interface SnapPoint {
time: number;
type: "element-start" | "element-end" | "playhead";
type: "element-start" | "element-end" | "playhead" | "bookmark";
elementId?: string;
trackId?: string;
}
@@ -19,22 +20,28 @@ export interface UseTimelineSnappingOptions {
snapThreshold?: number;
enableElementSnapping?: boolean;
enablePlayheadSnapping?: boolean;
enableBookmarkSnapping?: boolean;
}
export function useTimelineSnapping({
snapThreshold = 10,
enableElementSnapping = true,
enablePlayheadSnapping = true,
enableBookmarkSnapping = true,
}: UseTimelineSnappingOptions = {}) {
const findSnapPoints = useCallback(
({
tracks,
playheadTime,
excludeElementId,
bookmarks = [],
excludeBookmarkTime,
}: {
tracks: Array<TimelineTrack>;
playheadTime: number;
excludeElementId?: string;
bookmarks?: Array<Bookmark>;
excludeBookmarkTime?: number;
}): SnapPoint[] => {
const snapPoints: SnapPoint[] = [];
@@ -71,9 +78,25 @@ export function useTimelineSnapping({
});
}
if (enableBookmarkSnapping) {
for (const bookmark of bookmarks) {
if (
excludeBookmarkTime != null &&
Math.abs(bookmark.time - excludeBookmarkTime) <
BOOKMARK_TIME_EPSILON
) {
continue;
}
snapPoints.push({
time: bookmark.time,
type: "bookmark",
});
}
}
return snapPoints;
},
[enableElementSnapping, enablePlayheadSnapping],
[enableElementSnapping, enablePlayheadSnapping, enableBookmarkSnapping],
);
const snapToNearestPoint = useCallback(
@@ -118,6 +141,7 @@ export function useTimelineSnapping({
zoomLevel,
excludeElementId,
snapToStart = true,
bookmarks = [],
}: {
targetTime: number;
elementDuration: number;
@@ -126,11 +150,13 @@ export function useTimelineSnapping({
zoomLevel: number;
excludeElementId?: string;
snapToStart?: boolean;
bookmarks?: Array<Bookmark>;
}): SnapResult => {
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId,
bookmarks,
});
const effectiveTargetTime = snapToStart
@@ -57,6 +57,8 @@ export function useTimelineZoom({
const previousZoomRef = useRef(zoomLevel);
const hasRestoredScrollRef = useRef(false);
const preZoomScrollLeftRef = useRef(0);
const prePlayheadAnchorScrollLeftRef = useRef(0);
const isInPlayheadAnchorModeRef = useRef(false);
const setZoomLevel = useCallback(
(updater: number | ((prev: number) => number)) => {
@@ -89,8 +91,6 @@ export function useTimelineZoom({
);
return nextZoom;
});
// for horizontal scrolling (when shift is held or horizontal wheel movement),
// let the event bubble up to allow ScrollArea to handle it
return;
}
},
@@ -143,6 +143,36 @@ export function useTimelineZoom({
const currentScrollLeft = preZoomScrollLeftRef.current;
const playheadTime = editor.playback.getCurrentTime();
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
const previousSliderPercent = zoomToSlider({
zoomLevel: previousZoom,
minZoom,
});
const isCrossingThresholdUp =
previousSliderPercent <
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
const isCrossingThresholdDown =
previousSliderPercent >=
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
sliderPercent < TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
const syncScroll = (scrollLeft: number) => {
scrollElement.scrollLeft = scrollLeft;
if (rulerScrollRef.current) {
rulerScrollRef.current.scrollLeft = scrollLeft;
}
};
const clampScrollLeft = (scrollLeft: number) => {
const maxScrollLeft =
scrollElement.scrollWidth - scrollElement.clientWidth;
return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
};
if (isCrossingThresholdUp) {
prePlayheadAnchorScrollLeftRef.current = currentScrollLeft;
isInPlayheadAnchorModeRef.current = true;
}
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore =
@@ -153,17 +183,10 @@ export function useTimelineZoom({
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;
const maxScrollLeft =
scrollElement.scrollWidth - scrollElement.clientWidth;
const clampedScrollLeft = Math.max(
0,
Math.min(maxScrollLeft, newScrollLeft),
);
scrollElement.scrollLeft = clampedScrollLeft;
if (rulerScrollRef.current) {
rulerScrollRef.current.scrollLeft = clampedScrollLeft;
}
syncScroll(clampScrollLeft(newScrollLeft));
} else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) {
syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current));
isInPlayheadAnchorModeRef.current = false;
}
previousZoomRef.current = zoomLevel;
@@ -217,7 +240,6 @@ export function useTimelineZoom({
const observer = new ResizeObserver(() => {
if (scrollElement.scrollWidth > 0) {
restoreScroll();
hasRestoredScrollRef.current = true;
observer.disconnect();
}
});
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useRef } from "react";
type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
const DATA_ATTR = "data-focus-locked";
function buildFocusLockCSS({
cursor,
allowSelector,
}: {
cursor: FocusLockCursor;
allowSelector?: string;
}) {
const rules = [
`*, *::before, *::after { pointer-events: none !important; cursor: ${cursor} !important; }`,
`[${DATA_ATTR}], [${DATA_ATTR}] * { pointer-events: auto !important; cursor: auto !important; }`,
];
if (allowSelector) {
rules.push(
`${allowSelector} { pointer-events: auto !important; cursor: auto !important; }`,
);
}
return rules.join("\n");
}
export function useFocusLock<T extends HTMLElement = HTMLElement>({
isActive,
onDismiss,
cursor = "default",
allowSelector,
}: {
isActive: boolean;
onDismiss: () => void;
cursor?: FocusLockCursor;
allowSelector?: string;
}) {
const containerRef = useRef<T>(null);
const onDismissRef = useRef(onDismiss);
onDismissRef.current = onDismiss;
useEffect(() => {
if (!isActive) return;
const container = containerRef.current;
if (!container) return;
container.setAttribute(DATA_ATTR, "");
const focusLockStyle = document.createElement("style");
focusLockStyle.textContent = buildFocusLockCSS({ cursor, allowSelector });
document.head.appendChild(focusLockStyle);
const handleOutsidePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
if (container.contains(event.target as Node)) return;
const target = event.target as Element | null;
const isAllowedTarget = allowSelector && target?.closest(allowSelector);
if (isAllowedTarget) return;
onDismissRef.current();
};
document.addEventListener("pointerdown", handleOutsidePointerDown, true);
return () => {
document.removeEventListener(
"pointerdown",
handleOutsidePointerDown,
true,
);
container.removeAttribute(DATA_ATTR);
focusLockStyle.remove();
};
}, [isActive, cursor, allowSelector]);
return { containerRef };
}
+35 -35
View File
@@ -1,35 +1,35 @@
import { useRef, useCallback } from "react";
interface UseInfiniteScrollOptions {
onLoadMore: () => void;
hasMore: boolean;
isLoading: boolean;
threshold?: number;
enabled?: boolean;
}
export function useInfiniteScroll({
onLoadMore,
hasMore,
isLoading,
threshold = 200,
enabled = true,
}: UseInfiniteScrollOptions) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
if (!enabled) return;
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
if (nearBottom && hasMore && !isLoading) {
onLoadMore();
}
},
[onLoadMore, hasMore, isLoading, threshold, enabled],
);
return { scrollAreaRef, handleScroll };
}
import { useRef, useCallback } from "react";
interface UseInfiniteScrollOptions {
onLoadMore: () => void;
hasMore: boolean;
isLoading: boolean;
threshold?: number;
enabled?: boolean;
}
export function useInfiniteScroll({
onLoadMore,
hasMore,
isLoading,
threshold = 200,
enabled = true,
}: UseInfiniteScrollOptions) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
if (!enabled) return;
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
if (nearBottom && hasMore && !isLoading) {
onLoadMore();
}
},
[onLoadMore, hasMore, isLoading, threshold, enabled],
);
return { scrollAreaRef, handleScroll };
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect } from "react";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/types/assets";
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
function isMediaMimeType({ type }: { type: string }): boolean {
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
}
function extractMediaFilesFromClipboard({
clipboardData,
}: {
clipboardData: DataTransfer | null;
}): File[] {
if (!clipboardData?.items) return [];
const files: File[] = [];
for (const item of clipboardData.items) {
if (item.kind !== "file") continue;
if (!isMediaMimeType({ type: item.type })) continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
}
export function usePasteMedia() {
const editor = useEditor();
useEffect(() => {
const handlePaste = async (event: ClipboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && isTypableDOMElement({ element: activeElement })) {
return;
}
const files = extractMediaFilesFromClipboard({
clipboardData: event.clipboardData,
});
if (files.length === 0) return;
event.preventDefault();
const activeProject = editor.project.getActive();
if (!activeProject) return;
try {
const processedAssets = await processMediaAssets({ files });
const startTime = editor.playback.getCurrentTime();
for (const asset of processedAssets) {
const addMediaCmd = new AddMediaAssetCommand(
activeProject.metadata.id,
asset,
);
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const trackType = asset.type === "audio" ? "audio" : "video";
const element = buildElementFromMedia({
mediaId: assetId,
mediaType: asset.type,
name: asset.name,
duration,
startTime,
buffer:
asset.type === "audio"
? new AudioBuffer({ length: 1, sampleRate: 44100 })
: undefined,
});
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "auto", trackType },
});
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
editor.command.execute({ command: batchCmd });
}
} catch (error) {
console.error("Failed to paste media:", error);
toast.error("Failed to paste media");
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor]);
}
+235 -109
View File
@@ -1,12 +1,22 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useSyncExternalStore } from "react";
import type { Transform, TimelineTrack } from "@/types/timeline";
import { useShiftKey } from "@/hooks/use-shift-key";
import type { TextElement, Transform } from "@/types/timeline";
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
import { hitTest } from "@/lib/preview/hit-test";
import { screenToCanvas } from "@/lib/preview/preview-coords";
import { isVisualElement } from "@/lib/timeline/element-utils";
import { snapPosition, type SnapLine } from "@/lib/preview/preview-snap";
const MIN_DRAG_DISTANCE = 0.5;
interface DragState {
startX: number;
startY: number;
tracksSnapshot: TimelineTrack[];
bounds: {
width: number;
height: number;
};
elements: Array<{
trackId: string;
elementId: string;
@@ -20,59 +30,164 @@ export function usePreviewInteraction({
canvasRef: React.RefObject<HTMLCanvasElement | null>;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const [isDragging, setIsDragging] = useState(false);
const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
const [editingText, setEditingText] = useState<{
trackId: string;
elementId: string;
element: TextElement;
originalOpacity: number;
} | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const wasPlayingRef = useRef(editor.playback.getIsPlaying());
const editingTextRef = useRef(editingText);
editingTextRef.current = editingText;
const selectedElements = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getSelectedElements(),
);
const commitTextEdit = useCallback(() => {
const current = editingTextRef.current;
if (!current) return;
editor.timeline.previewElements({
updates: [
{
trackId: current.trackId,
elementId: current.elementId,
updates: { opacity: current.originalOpacity },
},
],
});
editor.timeline.commitPreview();
setEditingText(null);
}, [editor.timeline]);
const getCanvasCoordinates = useCallback(
({ clientX, clientY }: { clientX: number; clientY: number }) => {
if (!canvasRef.current) return { x: 0, y: 0 };
const cancelTextEdit = useCallback(() => {
editor.timeline.discardPreview();
setEditingText(null);
}, [editor.timeline]);
const rect = canvasRef.current.getBoundingClientRect();
const logicalWidth = canvasRef.current.width;
const logicalHeight = canvasRef.current.height;
const scaleX = logicalWidth / rect.width;
const scaleY = logicalHeight / rect.height;
useEffect(() => {
const unsubscribe = editor.playback.subscribe(() => {
const isPlaying = editor.playback.getIsPlaying();
if (isPlaying && !wasPlayingRef.current && editingTextRef.current) {
commitTextEdit();
}
wasPlayingRef.current = isPlaying;
});
return unsubscribe;
}, [editor.playback, commitTextEdit]);
const canvasX = (clientX - rect.left) * scaleX;
const canvasY = (clientY - rect.top) * scaleY;
const handleDoubleClick = useCallback(
({ clientX, clientY }: React.MouseEvent) => {
if (!canvasRef.current || editingText) return;
return { x: canvasX, y: canvasY };
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
const startPos = screenToCanvas({
clientX,
clientY,
canvas: canvasRef.current,
});
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
});
const hit = hitTest({
canvasX: startPos.x,
canvasY: startPos.y,
elementsWithBounds,
});
if (!hit || hit.element.type !== "text") return;
const textElement = hit.element as TextElement;
editor.timeline.previewElements({
updates: [
{
trackId: hit.trackId,
elementId: hit.elementId,
updates: { opacity: 0 },
},
],
});
setEditingText({
trackId: hit.trackId,
elementId: hit.elementId,
element: textElement,
originalOpacity: textElement.opacity,
});
},
[canvasRef],
[canvasRef, editor, editingText],
);
const handlePointerDown = useCallback(
(event: React.PointerEvent) => {
if (selectedElements.length === 0) return;
({
clientX,
clientY,
currentTarget,
pointerId,
button,
}: React.PointerEvent) => {
if (!canvasRef.current) return;
if (editingText) return;
if (button !== 0) return;
const elementsWithTracks = editor.timeline.getElementsWithTracks({
elements: selectedElements,
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
const startPos = screenToCanvas({
clientX,
clientY,
canvas: canvasRef.current,
});
const draggableElements = elementsWithTracks.filter(
({ element }) =>
element.type === "video" ||
element.type === "image" ||
element.type === "text" ||
element.type === "sticker",
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
});
const hit = hitTest({
canvasX: startPos.x,
canvasY: startPos.y,
elementsWithBounds,
});
if (!hit) {
editor.selection.clearSelection();
return;
}
editor.selection.setSelectedElements({
elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
});
const elementsWithTracks = editor.timeline.getElementsWithTracks({
elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
});
const draggableElements = elementsWithTracks.filter(({ element }) =>
isVisualElement(element),
);
if (draggableElements.length === 0) return;
const startPos = getCanvasCoordinates({
clientX: event.clientX,
clientY: event.clientY,
});
dragStateRef.current = {
startX: startPos.x,
startY: startPos.y,
tracksSnapshot: editor.timeline.getTracks(),
bounds: {
width: hit.bounds.width,
height: hit.bounds.height,
},
elements: draggableElements.map(({ track, element }) => ({
trackId: track.id,
elementId: element.id,
@@ -81,107 +196,118 @@ export function usePreviewInteraction({
};
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
currentTarget.setPointerCapture(pointerId);
},
[selectedElements, editor, getCanvasCoordinates],
[editor, canvasRef, editingText],
);
const handlePointerMove = useCallback(
(event: React.PointerEvent) => {
if (!dragStateRef.current || !isDragging) return;
({ clientX, clientY }: React.PointerEvent) => {
if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
const currentPos = getCanvasCoordinates({
clientX: event.clientX,
clientY: event.clientY,
const canvasSize = editor.project.getActive().settings.canvasSize;
const currentPos = screenToCanvas({
clientX,
clientY,
canvas: canvasRef.current,
});
const deltaX = currentPos.x - dragStateRef.current.startX;
const deltaY = currentPos.y - dragStateRef.current.startY;
for (const { trackId, elementId, initialTransform } of dragStateRef
.current.elements) {
const newPosition = {
x: initialTransform.position.x + deltaX,
y: initialTransform.position.y + deltaY,
};
editor.timeline.updateElements({
updates: [
{
trackId,
elementId,
updates: {
transform: {
...initialTransform,
position: newPosition,
},
},
},
],
pushHistory: false,
});
}
},
[isDragging, getCanvasCoordinates, editor],
);
const handlePointerUp = useCallback(
(event: React.PointerEvent) => {
if (!dragStateRef.current || !isDragging) return;
const currentPos = getCanvasCoordinates({
clientX: event.clientX,
clientY: event.clientY,
});
const deltaX = currentPos.x - dragStateRef.current.startX;
const deltaY = currentPos.y - dragStateRef.current.startY;
const hasMovement = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
const hasMovement =
Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
Math.abs(deltaY) > MIN_DRAG_DISTANCE;
if (!hasMovement) {
dragStateRef.current = null;
setIsDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
setSnapLines([]);
return;
}
// revert to pre-drag state so the command captures the correct undo snapshot
editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot);
const firstElement = dragStateRef.current.elements[0];
const proposedPosition = {
x: firstElement.initialTransform.position.x + deltaX,
y: firstElement.initialTransform.position.y + deltaY,
};
const shouldSnap = !isShiftHeldRef.current;
const { snappedPosition, activeLines } = shouldSnap
? snapPosition({
proposedPosition,
canvasSize,
elementSize: dragStateRef.current.bounds,
})
: {
snappedPosition: proposedPosition,
activeLines: [] as SnapLine[],
};
setSnapLines(activeLines);
const deltaSnappedX =
snappedPosition.x - firstElement.initialTransform.position.x;
const deltaSnappedY =
snappedPosition.y - firstElement.initialTransform.position.y;
const updates = dragStateRef.current.elements.map(
({ trackId, elementId, initialTransform }) => {
const newPosition = {
x: initialTransform.position.x + deltaX,
y: initialTransform.position.y + deltaY,
};
return {
trackId,
elementId,
updates: {
transform: {
...initialTransform,
position: newPosition,
({ trackId, elementId, initialTransform }) => ({
trackId,
elementId,
updates: {
transform: {
...initialTransform,
position: {
x: initialTransform.position.x + deltaSnappedX,
y: initialTransform.position.y + deltaSnappedY,
},
},
};
},
},
}),
);
editor.timeline.updateElements({ updates });
editor.timeline.previewElements({ updates });
},
[isDragging, canvasRef, editor, isShiftHeldRef],
);
const handlePointerUp = useCallback(
({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => {
if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
const currentPos = screenToCanvas({
clientX,
clientY,
canvas: canvasRef.current,
});
const deltaX = currentPos.x - dragStateRef.current.startX;
const deltaY = currentPos.y - dragStateRef.current.startY;
const hasMovement =
Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
Math.abs(deltaY) > MIN_DRAG_DISTANCE;
if (!hasMovement) {
editor.timeline.discardPreview();
} else {
editor.timeline.commitPreview();
}
dragStateRef.current = null;
setIsDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
setSnapLines([]);
currentTarget.releasePointerCapture(pointerId);
},
[isDragging, getCanvasCoordinates, editor],
[isDragging, canvasRef, editor],
);
return {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onDoubleClick: handleDoubleClick,
snapLines,
editingText,
commitTextEdit,
cancelTextEdit,
};
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef, type RefObject } from "react";
export function useShiftKey(): RefObject<boolean> {
const isShiftHeldRef = useRef(false);
useEffect(() => {
const handleKeyDown = ({ key }: KeyboardEvent) => {
if (key === "Shift") {
isShiftHeldRef.current = true;
}
};
const handleKeyUp = ({ key }: KeyboardEvent) => {
if (key === "Shift") {
isShiftHeldRef.current = false;
}
};
const handleBlur = () => {
isShiftHeldRef.current = false;
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, []);
return isShiftHeldRef;
}
+154 -154
View File
@@ -1,154 +1,154 @@
import { useEffect } from "react";
import { useSoundsStore } from "@/stores/sounds-store";
export function useSoundSearch({
query,
commercialOnly,
}: {
query: string;
commercialOnly: boolean;
}) {
const {
searchResults,
isSearching,
searchError,
lastSearchQuery,
currentPage,
hasNextPage,
isLoadingMore,
totalCount,
setSearchResults,
setSearching,
setSearchError,
setLastSearchQuery,
setCurrentPage,
setHasNextPage,
setTotalCount,
setLoadingMore,
appendSearchResults,
appendTopSounds,
resetPagination,
} = useSoundsStore();
const loadMore = async () => {
if (isLoadingMore || !hasNextPage) return;
try {
setLoadingMore({ loading: true });
const nextPage = currentPage + 1;
const searchParams = new URLSearchParams({
page: nextPage.toString(),
type: "effects",
});
if (query.trim()) {
searchParams.set("q", query);
}
searchParams.set("commercial_only", commercialOnly.toString());
const response = await fetch(
`/api/sounds/search?${searchParams.toString()}`,
);
if (response.ok) {
const data = await response.json();
if (query.trim()) {
appendSearchResults(data.results);
} else {
appendTopSounds(data.results);
}
setCurrentPage({ page: nextPage });
setHasNextPage({ hasNext: !!data.next });
setTotalCount(data.count);
} else {
setSearchError({ error: `Load more failed: ${response.status}` });
}
} catch (err) {
setSearchError({
error: err instanceof Error ? err.message : "Load more failed",
});
} finally {
setLoadingMore({ loading: false });
}
};
useEffect(() => {
if (!query.trim()) {
setSearchResults({ results: [] });
setSearchError({ error: null });
setLastSearchQuery({ query: "" });
return;
}
if (query === lastSearchQuery && searchResults.length > 0) {
return;
}
let ignore = false;
const timeoutId = setTimeout(async () => {
try {
setSearching({ searching: true });
setSearchError({ error: null });
resetPagination();
const response = await fetch(
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
);
if (!ignore) {
if (response.ok) {
const data = await response.json();
setSearchResults({ results: data.results });
setLastSearchQuery({ query: query });
setHasNextPage({ hasNext: !!data.next });
setTotalCount({ count: data.count });
setCurrentPage({ page: 1 });
} else {
setSearchError({ error: `Search failed: ${response.status}` });
}
}
} catch (err) {
if (!ignore) {
setSearchError({
error: err instanceof Error ? err.message : "Search failed",
});
}
} finally {
if (!ignore) {
setSearching({ searching: false });
}
}
}, 300);
return () => {
clearTimeout(timeoutId);
ignore = true;
};
}, [
query,
lastSearchQuery,
searchResults.length,
setSearchResults,
setSearching,
setSearchError,
setLastSearchQuery,
setCurrentPage,
setHasNextPage,
setTotalCount,
resetPagination,
]);
return {
results: searchResults,
isLoading: isSearching,
error: searchError,
loadMore,
hasNextPage,
isLoadingMore,
totalCount,
};
}
import { useEffect } from "react";
import { useSoundsStore } from "@/stores/sounds-store";
export function useSoundSearch({
query,
commercialOnly,
}: {
query: string;
commercialOnly: boolean;
}) {
const {
searchResults,
isSearching,
searchError,
lastSearchQuery,
currentPage,
hasNextPage,
isLoadingMore,
totalCount,
setSearchResults,
setSearching,
setSearchError,
setLastSearchQuery,
setCurrentPage,
setHasNextPage,
setTotalCount,
setLoadingMore,
appendSearchResults,
appendTopSounds,
resetPagination,
} = useSoundsStore();
const loadMore = async () => {
if (isLoadingMore || !hasNextPage) return;
try {
setLoadingMore({ loading: true });
const nextPage = currentPage + 1;
const searchParams = new URLSearchParams({
page: nextPage.toString(),
type: "effects",
});
if (query.trim()) {
searchParams.set("q", query);
}
searchParams.set("commercial_only", commercialOnly.toString());
const response = await fetch(
`/api/sounds/search?${searchParams.toString()}`,
);
if (response.ok) {
const data = await response.json();
if (query.trim()) {
appendSearchResults(data.results);
} else {
appendTopSounds(data.results);
}
setCurrentPage({ page: nextPage });
setHasNextPage({ hasNext: !!data.next });
setTotalCount(data.count);
} else {
setSearchError({ error: `Load more failed: ${response.status}` });
}
} catch (err) {
setSearchError({
error: err instanceof Error ? err.message : "Load more failed",
});
} finally {
setLoadingMore({ loading: false });
}
};
useEffect(() => {
if (!query.trim()) {
setSearchResults({ results: [] });
setSearchError({ error: null });
setLastSearchQuery({ query: "" });
return;
}
if (query === lastSearchQuery && searchResults.length > 0) {
return;
}
let ignore = false;
const timeoutId = setTimeout(async () => {
try {
setSearching({ searching: true });
setSearchError({ error: null });
resetPagination();
const response = await fetch(
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
);
if (!ignore) {
if (response.ok) {
const data = await response.json();
setSearchResults({ results: data.results });
setLastSearchQuery({ query: query });
setHasNextPage({ hasNext: !!data.next });
setTotalCount({ count: data.count });
setCurrentPage({ page: 1 });
} else {
setSearchError({ error: `Search failed: ${response.status}` });
}
}
} catch (err) {
if (!ignore) {
setSearchError({
error: err instanceof Error ? err.message : "Search failed",
});
}
} finally {
if (!ignore) {
setSearching({ searching: false });
}
}
}, 300);
return () => {
clearTimeout(timeoutId);
ignore = true;
};
}, [
query,
lastSearchQuery,
searchResults.length,
setSearchResults,
setSearching,
setSearchError,
setLastSearchQuery,
setCurrentPage,
setHasNextPage,
setTotalCount,
resetPagination,
]);
return {
results: searchResults,
isLoading: isSearching,
error: searchError,
loadMore,
hasNextPage,
isLoadingMore,
totalCount,
};
}
+331
View File
@@ -0,0 +1,331 @@
import { useCallback, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useSyncExternalStore } from "react";
import {
getVisibleElementsWithBounds,
type ElementWithBounds,
} from "@/lib/preview/element-bounds";
import { screenToCanvas } from "@/lib/preview/preview-coords";
import {
MIN_SCALE,
snapRotation,
snapScale,
type SnapLine,
} from "@/lib/preview/preview-snap";
import { isVisualElement } from "@/lib/timeline/element-utils";
import type { Transform } from "@/types/timeline";
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type HandleType = Corner | "rotation";
interface ScaleState {
trackId: string;
elementId: string;
initialTransform: Transform;
initialDistance: number;
initialBoundsCx: number;
initialBoundsCy: number;
baseWidth: number;
baseHeight: number;
}
interface RotationState {
trackId: string;
elementId: string;
initialTransform: Transform;
initialAngle: number;
initialBoundsCx: number;
initialBoundsCy: number;
}
function areSnapLinesEqual({
previousLines,
nextLines,
}: {
previousLines: SnapLine[];
nextLines: SnapLine[];
}): boolean {
if (previousLines.length !== nextLines.length) {
return false;
}
for (const [index, line] of previousLines.entries()) {
const nextLine = nextLines[index];
if (!nextLine) {
return false;
}
if (line.type !== nextLine.type || line.position !== nextLine.position) {
return false;
}
}
return true;
}
function getCornerDistance({
bounds,
corner,
}: {
bounds: {
cx: number;
cy: number;
width: number;
height: number;
rotation: number;
};
corner: Corner;
}): number {
const halfW = bounds.width / 2;
const halfH = bounds.height / 2;
const angleRad = (bounds.rotation * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const localX =
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
const localY =
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
const rotatedX = localX * cos - localY * sin;
const rotatedY = localX * sin + localY * cos;
return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
}
export function useTransformHandles({
canvasRef,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
const snapLinesRef = useRef<SnapLine[]>([]);
const scaleStateRef = useRef<ScaleState | null>(null);
const rotationStateRef = useRef<RotationState | null>(null);
const selectedElements = useSyncExternalStore(
(listener) => editor.selection.subscribe(listener),
() => editor.selection.getSelectedElements(),
);
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
});
const selectedWithBounds: ElementWithBounds | null =
selectedElements.length === 1
? (elementsWithBounds.find(
(entry) =>
entry.trackId === selectedElements[0].trackId &&
entry.elementId === selectedElements[0].elementId,
) ?? null)
: null;
const hasVisualSelection =
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
const handleCornerPointerDown = useCallback(
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
if (!selectedWithBounds) return;
event.stopPropagation();
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
const initialDistance = getCornerDistance({ bounds, corner });
const baseWidth = bounds.width / element.transform.scale;
const baseHeight = bounds.height / element.transform.scale;
scaleStateRef.current = {
trackId,
elementId,
initialTransform: element.transform,
initialDistance,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
baseWidth,
baseHeight,
};
setActiveHandle(corner);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
},
[selectedWithBounds],
);
const handleRotationPointerDown = useCallback(
({ event }: { event: React.PointerEvent }) => {
if (!selectedWithBounds || !canvasRef.current) return;
event.stopPropagation();
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
const position = screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
canvas: canvasRef.current,
});
const dx = position.x - bounds.cx;
const dy = position.y - bounds.cy;
const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
rotationStateRef.current = {
trackId,
elementId,
initialTransform: element.transform,
initialAngle,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
};
setActiveHandle("rotation");
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
},
[selectedWithBounds, canvasRef],
);
const handlePointerMove = useCallback(
({ event }: { event: React.PointerEvent }) => {
if (!canvasRef.current) return;
if (!scaleStateRef.current && !rotationStateRef.current) return;
const position = screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
canvas: canvasRef.current,
});
if (
scaleStateRef.current &&
activeHandle &&
activeHandle !== "rotation"
) {
const {
trackId,
elementId,
initialTransform,
initialDistance,
initialBoundsCx,
initialBoundsCy,
baseWidth,
baseHeight,
} = scaleStateRef.current;
const dx = position.x - initialBoundsCx;
const dy = position.y - initialBoundsCy;
const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1;
const scaleFactor = currentDistance / initialDistance;
const proposedScale = Math.max(
MIN_SCALE,
initialTransform.scale * scaleFactor,
);
const canvasSize = editor.project.getActive().settings.canvasSize;
const shouldSnap = !isShiftHeldRef.current;
const { snappedScale, activeLines } = shouldSnap
? snapScale({
proposedScale,
position: initialTransform.position,
baseWidth,
baseHeight,
canvasSize,
})
: { snappedScale: proposedScale, activeLines: [] as SnapLine[] };
const isSameLines = areSnapLinesEqual({
previousLines: snapLinesRef.current,
nextLines: activeLines,
});
if (!isSameLines) {
snapLinesRef.current = activeLines;
setSnapLines(activeLines);
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
transform: { ...initialTransform, scale: snappedScale },
},
},
],
});
return;
}
if (rotationStateRef.current && activeHandle === "rotation") {
const {
trackId,
elementId,
initialTransform,
initialAngle,
initialBoundsCx,
initialBoundsCy,
} = rotationStateRef.current;
const dx = position.x - initialBoundsCx;
const dy = position.y - initialBoundsCy;
const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
let deltaAngle = currentAngle - initialAngle;
if (deltaAngle > 180) deltaAngle -= 360;
if (deltaAngle < -180) deltaAngle += 360;
const newRotate = initialTransform.rotate + deltaAngle;
const shouldSnapRotation = !isShiftHeldRef.current;
const { snappedRotation } = shouldSnapRotation
? snapRotation({ proposedRotation: newRotate })
: { snappedRotation: newRotate };
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
transform: { ...initialTransform, rotate: snappedRotation },
},
},
],
});
}
},
[activeHandle, canvasRef, editor, isShiftHeldRef],
);
const handlePointerUp = useCallback(
({ event }: { event: React.PointerEvent }) => {
if (scaleStateRef.current || rotationStateRef.current) {
editor.timeline.commitPreview();
scaleStateRef.current = null;
rotationStateRef.current = null;
setActiveHandle(null);
snapLinesRef.current = [];
setSnapLines([]);
}
(event.currentTarget as HTMLElement).releasePointerCapture(
event.pointerId,
);
},
[editor],
);
return {
selectedWithBounds,
hasVisualSelection,
activeHandle,
snapLines,
handleCornerPointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
};
}