summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/hooks
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/hooks')
-rw-r--r--apps/web/src/hooks/actions/use-action-handler.ts52
-rw-r--r--apps/web/src/hooks/actions/use-editor-actions.ts488
-rw-r--r--apps/web/src/hooks/storage/use-local-storage.ts58
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts760
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-selection.ts137
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts222
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-drag.ts314
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts229
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts278
-rw-r--r--apps/web/src/hooks/timeline/use-edge-auto-scroll.ts97
-rw-r--r--apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts42
-rw-r--r--apps/web/src/hooks/timeline/use-scroll-position.ts52
-rw-r--r--apps/web/src/hooks/timeline/use-scroll-sync.ts173
-rw-r--r--apps/web/src/hooks/timeline/use-snap-indicator-position.ts55
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts648
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts290
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-resize.ts322
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-seek.ts196
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts297
-rw-r--r--apps/web/src/hooks/use-editor.ts60
-rw-r--r--apps/web/src/hooks/use-element-preview.ts39
-rw-r--r--apps/web/src/hooks/use-file-upload.ts101
-rw-r--r--apps/web/src/hooks/use-font-atlas.ts53
-rw-r--r--apps/web/src/hooks/use-keybindings.ts87
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts83
-rw-r--r--apps/web/src/hooks/use-mask-handles.ts633
-rw-r--r--apps/web/src/hooks/use-menu-preview.ts26
-rw-r--r--apps/web/src/hooks/use-paste-media.ts116
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts456
-rw-r--r--apps/web/src/hooks/use-sound-search.ts154
-rw-r--r--apps/web/src/hooks/use-storage-persistence.ts45
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts631
32 files changed, 0 insertions, 7194 deletions
diff --git a/apps/web/src/hooks/actions/use-action-handler.ts b/apps/web/src/hooks/actions/use-action-handler.ts
deleted file mode 100644
index 3f6dbd21..00000000
--- a/apps/web/src/hooks/actions/use-action-handler.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import type {
- TAction,
- TActionFunc,
- TActionHandlerOptions,
- TArgOfAction,
- TInvocationTrigger,
-} from "@/lib/actions";
-import { bindAction, unbindAction } from "@/lib/actions";
-
-export function useActionHandler<A extends TAction>(
- action: A,
- handler: TActionFunc<A>,
- isActive: TActionHandlerOptions,
-) {
- const handlerRef = useRef<TActionFunc<A>>(handler);
- const isBoundRef = useRef(false);
-
- useEffect(() => {
- handlerRef.current = handler;
- }, [handler]);
-
- const stableHandler = useCallback(
- (...parameters: [TArgOfAction<A>, TInvocationTrigger?]) => {
- (
- handlerRef.current as (
- ...handlerParameters: [TArgOfAction<A>, TInvocationTrigger?]
- ) => void
- )(...parameters);
- },
- [],
- ) as TActionFunc<A>;
-
- useEffect(() => {
- const shouldBind =
- isActive === undefined ||
- (typeof isActive === "boolean" ? isActive : isActive.current);
-
- if (shouldBind && !isBoundRef.current) {
- bindAction(action, stableHandler);
- isBoundRef.current = true;
- } else if (!shouldBind && isBoundRef.current) {
- unbindAction(action, stableHandler);
- isBoundRef.current = false;
- }
-
- return () => {
- unbindAction(action, stableHandler);
- isBoundRef.current = false;
- };
- }, [action, stableHandler, isActive]);
-}
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
deleted file mode 100644
index 873fd33a..00000000
--- a/apps/web/src/hooks/actions/use-editor-actions.ts
+++ /dev/null
@@ -1,488 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useActionHandler } from "@/hooks/actions/use-action-handler";
-import { useEditor } from "../use-editor";
-import { useElementSelection } from "../timeline/element/use-element-selection";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
-import { getElementsAtTime, hasMediaId } from "@/lib/timeline";
-import { cancelInteraction } from "@/lib/cancel-interaction";
-import { invokeAction } from "@/lib/actions";
-import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
-import {
- activateScope,
- clearActiveScope,
- type ScopeEntry,
-} from "@/lib/selection/scope";
-
-export function useEditorActions() {
- const editor = useEditor();
- const { selectedElements, setElementSelection } = useElementSelection();
- const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
- const selectedMaskPointSelection = useEditor((e) =>
- e.selection.getSelectedMaskPointSelection(),
- );
- const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
- const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
- const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
- const hasTimelineSelectionRef = useRef(false);
- const clearTimelineSelectionRef = useRef(() => {});
- const clearTimelineActiveSelectionRef = useRef(() => {});
- const timelineScopeRef = useRef<ScopeEntry | null>(null);
- const hasTimelineSelection =
- selectedElements.length > 0 ||
- selectedKeyframes.length > 0 ||
- selectedMaskPointSelection !== null;
-
- hasTimelineSelectionRef.current = hasTimelineSelection;
- clearTimelineSelectionRef.current = () => {
- editor.selection.clearSelection();
- };
- clearTimelineActiveSelectionRef.current = () => {
- editor.selection.clearMostSpecificSelection();
- };
-
- if (!timelineScopeRef.current) {
- timelineScopeRef.current = {
- hasSelection: () => hasTimelineSelectionRef.current,
- clear: () => {
- clearTimelineSelectionRef.current();
- },
- clearActive: () => {
- clearTimelineActiveSelectionRef.current();
- },
- };
- }
-
- useEffect(() => {
- if (!hasTimelineSelection) {
- return;
- }
-
- const timelineScope = timelineScopeRef.current;
- if (!timelineScope) {
- return;
- }
-
- return activateScope({ entry: timelineScope });
- }, [hasTimelineSelection]);
-
- useActionHandler(
- "toggle-play",
- () => {
- editor.playback.toggle();
- },
- undefined,
- );
-
- useActionHandler(
- "stop-playback",
- () => {
- if (editor.playback.getIsPlaying()) {
- editor.playback.toggle();
- }
- editor.playback.seek({ time: 0 });
- },
- undefined,
- );
-
- useActionHandler(
- "seek-forward",
- (args) => {
- const seconds = args?.seconds ?? 1;
- editor.playback.seek({
- time: Math.min(
- editor.timeline.getTotalDuration(),
- editor.playback.getCurrentTime() + seconds,
- ),
- });
- },
- undefined,
- );
-
- useActionHandler(
- "seek-backward",
- (args) => {
- const seconds = args?.seconds ?? 1;
- editor.playback.seek({
- time: Math.max(0, editor.playback.getCurrentTime() - seconds),
- });
- },
- undefined,
- );
-
- useActionHandler(
- "frame-step-forward",
- () => {
- const fps = editor.project.getActive().settings.fps;
- const ticksPerFrame = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
- editor.playback.seek({
- time: Math.min(
- editor.timeline.getTotalDuration(),
- editor.playback.getCurrentTime() + ticksPerFrame,
- ),
- });
- },
- undefined,
- );
-
- useActionHandler(
- "frame-step-backward",
- () => {
- const fps = editor.project.getActive().settings.fps;
- const ticksPerFrame = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
- editor.playback.seek({
- time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
- });
- },
- undefined,
- );
-
- useActionHandler(
- "jump-forward",
- (args) => {
- const seconds = args?.seconds ?? 5;
- editor.playback.seek({
- time: Math.min(
- editor.timeline.getTotalDuration(),
- editor.playback.getCurrentTime() + seconds,
- ),
- });
- },
- undefined,
- );
-
- useActionHandler(
- "jump-backward",
- (args) => {
- const seconds = args?.seconds ?? 5;
- editor.playback.seek({
- time: Math.max(0, editor.playback.getCurrentTime() - seconds),
- });
- },
- undefined,
- );
-
- useActionHandler(
- "goto-start",
- () => {
- editor.playback.seek({ time: 0 });
- },
- undefined,
- );
-
- useActionHandler(
- "goto-end",
- () => {
- editor.playback.seek({ time: editor.timeline.getTotalDuration() });
- },
- undefined,
- );
-
- useActionHandler(
- "split",
- () => {
- const currentTime = editor.playback.getCurrentTime();
- const tracks = editor.scenes.getActiveScene().tracks;
- const elementsToSplit =
- selectedElements.length > 0
- ? selectedElements
- : getElementsAtTime({
- tracks,
- time: currentTime,
- });
-
- if (elementsToSplit.length === 0) return;
-
- editor.timeline.splitElements({
- elements: elementsToSplit,
- splitTime: currentTime,
- });
- },
- undefined,
- );
-
- useActionHandler(
- "split-left",
- () => {
- const currentTime = editor.playback.getCurrentTime();
- const tracks = editor.scenes.getActiveScene().tracks;
- const elementsToSplit =
- selectedElements.length > 0
- ? selectedElements
- : getElementsAtTime({
- tracks,
- time: currentTime,
- });
-
- if (elementsToSplit.length === 0) return;
-
- const rightSideElements = editor.timeline.splitElements({
- elements: elementsToSplit,
- splitTime: currentTime,
- retainSide: "right",
- });
-
- if (rippleEditingEnabled && rightSideElements.length > 0) {
- const firstRightElement = editor.timeline.getElementsWithTracks({
- elements: [rightSideElements[0]],
- })[0];
- if (firstRightElement) {
- editor.playback.seek({ time: firstRightElement.element.startTime });
- }
- }
- },
- undefined,
- );
-
- useActionHandler(
- "split-right",
- () => {
- const currentTime = editor.playback.getCurrentTime();
- const tracks = editor.scenes.getActiveScene().tracks;
- const elementsToSplit =
- selectedElements.length > 0
- ? selectedElements
- : getElementsAtTime({
- tracks,
- time: currentTime,
- });
-
- if (elementsToSplit.length === 0) return;
-
- editor.timeline.splitElements({
- elements: elementsToSplit,
- splitTime: currentTime,
- retainSide: "left",
- });
- },
- undefined,
- );
-
- useActionHandler(
- "delete-selected",
- () => {
- switch (editor.selection.getActiveSelectionKind()) {
- case "mask-points":
- if (!selectedMaskPointSelection) {
- return;
- }
- editor.timeline.deleteCustomMaskPoints({
- trackId: selectedMaskPointSelection.trackId,
- elementId: selectedMaskPointSelection.elementId,
- maskId: selectedMaskPointSelection.maskId,
- pointIds: selectedMaskPointSelection.pointIds,
- });
- return;
- case "keyframes":
- if (selectedKeyframes.length === 0) {
- return;
- }
- editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
- clearKeyframeSelection();
- return;
- case "elements":
- if (selectedElements.length === 0) {
- return;
- }
- editor.timeline.deleteElements({
- elements: selectedElements,
- });
- return;
- default:
- return;
- }
- },
- undefined,
- );
-
- useActionHandler(
- "toggle-source-audio",
- () => {
- if (selectedElements.length !== 1) {
- return;
- }
-
- const selectedElement = editor.timeline.getElementsWithTracks({
- elements: selectedElements,
- })[0];
- if (!selectedElement) {
- return;
- }
-
- const mediaAsset = (() => {
- const { element } = selectedElement;
- if (!hasMediaId(element)) {
- return null;
- }
-
- return (
- editor.media
- .getAssets()
- .find((asset) => asset.id === element.mediaId) ?? null
- );
- })();
- if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
- return;
- }
-
- editor.timeline.toggleSourceAudioSeparation({
- trackId: selectedElement.track.id,
- elementId: selectedElement.element.id,
- });
- },
- undefined,
- );
-
- useActionHandler(
- "select-all",
- () => {
- const scene = editor.scenes.getActiveScene();
- const allElements = [
- ...scene.tracks.overlay,
- scene.tracks.main,
- ...scene.tracks.audio,
- ].flatMap((track) =>
- track.elements.map((element) => ({
- trackId: track.id,
- elementId: element.id,
- })),
- );
- setElementSelection({ elements: allElements });
- },
- undefined,
- );
-
- useActionHandler(
- "cancel-interaction",
- () => {
- if (!cancelInteraction()) {
- invokeAction("deselect-all");
- }
- },
- undefined,
- );
-
- useActionHandler(
- "deselect-all",
- () => {
- if (!clearActiveScope()) {
- editor.selection.clearMostSpecificSelection();
- }
- },
- undefined,
- );
-
- useActionHandler(
- "duplicate-selected",
- () => {
- editor.timeline.duplicateElements({
- elements: selectedElements,
- });
- },
- undefined,
- );
-
- useActionHandler(
- "toggle-elements-muted-selected",
- () => {
- editor.timeline.toggleElementsMuted({ elements: selectedElements });
- },
- undefined,
- );
-
- useActionHandler(
- "toggle-elements-visibility-selected",
- () => {
- editor.timeline.toggleElementsVisibility({ elements: selectedElements });
- },
- undefined,
- );
-
- useActionHandler(
- "toggle-bookmark",
- () => {
- editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() });
- },
- undefined,
- );
-
- useActionHandler(
- "copy-selected",
- () => {
- editor.clipboard.copy();
- },
- undefined,
- );
-
- useActionHandler(
- "paste-copied",
- () => {
- editor.clipboard.paste();
- },
- undefined,
- );
-
- useActionHandler(
- "toggle-snapping",
- () => {
- toggleSnapping();
- },
- undefined,
- );
-
- useActionHandler(
- "toggle-ripple-editing",
- () => {
- toggleRippleEditing();
- },
- undefined,
- );
-
- useActionHandler(
- "undo",
- () => {
- editor.command.undo();
- },
- undefined,
- );
-
- useActionHandler(
- "redo",
- () => {
- editor.command.redo();
- },
- undefined,
- );
-
- // todo: potnetially unify these two actions:
- useActionHandler(
- "remove-media-asset",
- (args) => {
- if (!args) return;
- editor.media.removeMediaAsset({
- projectId: args.projectId,
- id: args.assetId,
- });
- },
- undefined,
- );
-
- useActionHandler(
- "remove-media-assets",
- (args) => {
- if (!args) return;
- editor.media.removeMediaAssets({
- projectId: args.projectId,
- ids: args.assetIds,
- });
- },
- undefined,
- );
-}
diff --git a/apps/web/src/hooks/storage/use-local-storage.ts b/apps/web/src/hooks/storage/use-local-storage.ts
deleted file mode 100644
index d0188ab3..00000000
--- a/apps/web/src/hooks/storage/use-local-storage.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useState, useEffect, useCallback, useRef } from "react";
-
-export function useLocalStorage<T>({
- key,
- defaultValue,
-}: {
- key: string;
- defaultValue: T;
-}): [
- T,
- ({ value }: { value: T | ((previousValue: T) => T) }) => void,
- boolean,
-] {
- const [value, setValue] = useState<T>(defaultValue);
- const [isReady, setIsReady] = useState(false);
- const valueRef = useRef(defaultValue);
-
- // avoid hydration mismatch by reading after mount
- useEffect(() => {
- try {
- const storedValue = localStorage.getItem(key);
- if (storedValue !== null) {
- const parsedValue = JSON.parse(storedValue) as T;
- valueRef.current = parsedValue;
- setValue(parsedValue);
- }
- } catch {
- // localstorage might be unavailable
- }
- setIsReady(true);
- }, [key]);
-
- // sync to localstorage after hydration
- useEffect(() => {
- if (!isReady) return;
-
- try {
- localStorage.setItem(key, JSON.stringify(value));
- } catch {
- // localstorage might be full or disabled
- }
- }, [key, value, isReady]);
-
- const setValueWithCallback = useCallback(
- ({ value: nextValue }: { value: T | ((previousValue: T) => T) }) => {
- const resolvedValue =
- typeof nextValue === "function"
- ? (nextValue as (previousValue: T) => T)(valueRef.current)
- : nextValue;
-
- valueRef.current = resolvedValue;
- setValue(resolvedValue);
- },
- [],
- );
-
- return [value, setValueWithCallback, isReady];
-}
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
deleted file mode 100644
index 5458efe7..00000000
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ /dev/null
@@ -1,760 +0,0 @@
-import {
- useState,
- useCallback,
- useEffect,
- useRef,
- type MouseEvent as ReactMouseEvent,
- 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 {
- buildMoveGroup,
- resolveGroupMove,
- snapGroupEdges,
- type GroupMoveResult,
- type MoveGroup,
-} from "@/lib/timeline/group-move";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
-import { roundToFrame } from "opencut-wasm";
-import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
-import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
-import { generateUUID } from "@/utils/id";
-import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { registerCanceller } from "@/lib/cancel-interaction";
-import type {
- DropTarget,
- ElementRef,
- ElementDragState,
- SceneTracks,
- TimelineElement,
- TimelineTrack,
-} from "@/lib/timeline";
-
-interface UseElementInteractionProps {
- zoomLevel: number;
- timelineRef: RefObject<HTMLDivElement | null>;
- tracksContainerRef: RefObject<HTMLDivElement | null>;
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- headerRef?: RefObject<HTMLElement | null>;
- snappingEnabled: boolean;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}
-
-const MOUSE_BUTTON_RIGHT = 2;
-
-const initialDragState: ElementDragState = {
- isDragging: false,
- elementId: null,
- dragElementIds: [],
- dragTimeOffsets: {},
- trackId: null,
- startMouseX: 0,
- startMouseY: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- currentMouseY: 0,
-};
-
-interface PendingDragState {
- elementId: string;
- trackId: string;
- selectedElements: ElementRef[];
- startMouseX: number;
- startMouseY: number;
- startElementTime: number;
- clickOffsetTime: number;
-}
-
-function getClickOffsetTime({
- clientX,
- elementRect,
- zoomLevel,
-}: {
- clientX: number;
- elementRect: DOMRect;
- zoomLevel: number;
-}): number {
- const clickOffsetX = clientX - elementRect.left;
- const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
- return Math.round(seconds * TICKS_PER_SECOND);
-}
-
-function getVerticalDragDirection({
- startMouseY,
- currentMouseY,
-}: {
- startMouseY: number;
- currentMouseY: number;
-}): "up" | "down" | null {
- if (currentMouseY < startMouseY) return "up";
- if (currentMouseY > startMouseY) return "down";
- return null;
-}
-
-function getDragDropTarget({
- clientX,
- clientY,
- elementId,
- trackId,
- tracks,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- zoomLevel,
- snappedTime,
- verticalDragDirection,
-}: {
- clientX: number;
- clientY: number;
- elementId: string;
- trackId: string;
- tracks: SceneTracks;
- tracksContainerRef: RefObject<HTMLDivElement | null>;
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- headerRef?: RefObject<HTMLElement | null>;
- zoomLevel: number;
- snappedTime: number;
- verticalDragDirection?: "up" | "down" | null;
-}): DropTarget | null {
- const containerRect = tracksContainerRef.current?.getBoundingClientRect();
- const scrollContainer = tracksScrollRef.current;
- if (!containerRect || !scrollContainer) return null;
-
- const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find(
- ({ id }) => id === trackId,
- );
- const movingElement = sourceTrack?.elements.find(
- ({ id }) => id === elementId,
- );
- if (!movingElement) return null;
-
- const elementDuration = movingElement.duration;
- const scrollLeft = scrollContainer.scrollLeft;
- const scrollTop = scrollContainer.scrollTop;
- const scrollContainerRect = scrollContainer.getBoundingClientRect();
- const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0;
- const mouseX = clientX - scrollContainerRect.left + scrollLeft;
- const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight;
-
- return computeDropTarget({
- elementType: movingElement.type,
- mouseX,
- mouseY,
- tracks,
- playheadTime: snappedTime,
- isExternalDrop: false,
- elementDuration,
- pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
- zoomLevel,
- startTimeOverride: snappedTime,
- excludeElementId: movingElement.id,
- verticalDragDirection,
- });
-}
-
-interface StartDragParams
- extends Omit<
- ElementDragState,
- "isDragging" | "currentTime" | "currentMouseY"
- > {
- initialCurrentTime: number;
- initialCurrentMouseY: number;
-}
-
-export function useElementInteraction({
- zoomLevel,
- timelineRef,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- snappingEnabled,
- onSnapPointChange,
-}: UseElementInteractionProps) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const sceneTracks = editor.scenes.getActiveScene().tracks;
- const {
- selectedElements,
- isElementSelected,
- selectElement,
- handleElementClick: handleSelectionClick,
- } = useElementSelection();
-
- 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 moveGroupRef = useRef<MoveGroup | null>(null);
- const newTrackIdsRef = useRef<string[]>([]);
- const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
- const lastMouseXRef = useRef(0);
- const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
-
- const startDrag = useCallback(
- ({
- elementId,
- dragElementIds,
- dragTimeOffsets,
- trackId,
- startMouseX,
- startMouseY,
- startElementTime,
- clickOffsetTime,
- initialCurrentTime,
- initialCurrentMouseY,
- }: StartDragParams) => {
- setDragState({
- isDragging: true,
- elementId,
- dragElementIds,
- dragTimeOffsets,
- trackId,
- startMouseX,
- startMouseY,
- startElementTime,
- clickOffsetTime,
- currentTime: initialCurrentTime,
- currentMouseY: initialCurrentMouseY,
- });
- },
- [],
- );
-
- const endDrag = useCallback(() => {
- moveGroupRef.current = null;
- newTrackIdsRef.current = [];
- groupMoveResultRef.current = null;
- setDragState(initialDragState);
- setDragDropTarget(null);
- }, []);
-
- const cancelCurrentDrag = useCallback(() => {
- pendingDragRef.current = null;
- mouseDownLocationRef.current = null;
- setIsPendingDrag(false);
- endDrag();
- onSnapPointChange?.(null);
- }, [endDrag, onSnapPointChange]);
-
- const resolveGroupDragMove = useCallback(
- ({
- group,
- snappedTime,
- dropTarget,
- }: {
- group: MoveGroup;
- snappedTime: number;
- dropTarget: DropTarget | null;
- }): GroupMoveResult | null => {
- if (!dropTarget) {
- return null;
- }
-
- if (dropTarget.isNewTrack) {
- return resolveGroupMove({
- group,
- tracks: sceneTracks,
- anchorStartTime: snappedTime,
- target: {
- kind: "newTracks",
- anchorInsertIndex: dropTarget.trackIndex,
- newTrackIds: newTrackIdsRef.current,
- },
- });
- }
-
- const orderedTracks = [
- ...sceneTracks.overlay,
- sceneTracks.main,
- ...sceneTracks.audio,
- ];
- const targetTrack = orderedTracks[dropTarget.trackIndex];
- if (!targetTrack) {
- return null;
- }
-
- const existingTrackResult = resolveGroupMove({
- group,
- tracks: sceneTracks,
- anchorStartTime: snappedTime,
- target: {
- kind: "existingTrack",
- anchorTargetTrackId: targetTrack.id,
- },
- });
- if (existingTrackResult) {
- return existingTrackResult;
- }
-
- return resolveGroupMove({
- group,
- tracks: sceneTracks,
- anchorStartTime: snappedTime,
- target: {
- kind: "newTracks",
- anchorInsertIndex: dropTarget.trackIndex,
- newTrackIds: newTrackIdsRef.current,
- },
- });
- },
- [sceneTracks],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- return registerCanceller({ fn: cancelCurrentDrag });
- }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
-
- const getDragSnapResult = useCallback(
- ({
- frameSnappedTime,
- group,
- }: {
- frameSnappedTime: number;
- group: MoveGroup | null;
- }) => {
- if (!group || !snappingEnabled || isShiftHeldRef.current) {
- return { snappedTime: frameSnappedTime, snapPoint: null };
- }
-
- const groupSnap = snapGroupEdges({
- group,
- anchorStartTime: frameSnappedTime,
- tracks: sceneTracks,
- playheadTime: editor.playback.getCurrentTime(),
- zoomLevel,
- });
-
- return {
- snappedTime: groupSnap.snappedAnchorStartTime,
- snapPoint: groupSnap.snapPoint,
- };
- },
- [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
- let startedDragThisEvent = false;
- const timeline = timelineRef.current;
- const scrollContainer = tracksScrollRef.current;
- if (!timeline || !scrollContainer) return;
- lastMouseXRef.current = clientX;
-
- if (isPendingDrag && pendingDragRef.current) {
- const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
- const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
- if (
- deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
- deltaY > TIMELINE_DRAG_THRESHOLD_PX
- ) {
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
- const scrollLeft = scrollContainer.scrollLeft;
- const mouseTime = getMouseTimeFromClientX({
- clientX,
- containerRect: scrollContainer.getBoundingClientRect(),
- zoomLevel,
- scrollLeft,
- });
- const adjustedTime = Math.max(
- 0,
- mouseTime - pendingDragRef.current.clickOffsetTime,
- );
- const snappedTime =
- roundToFrame({
- time: adjustedTime,
- rate: activeProject.settings.fps,
- }) ?? adjustedTime;
- const moveGroup = buildMoveGroup({
- anchorRef: {
- trackId: pendingDragRef.current.trackId,
- elementId: pendingDragRef.current.elementId,
- },
- selectedElements: pendingDragRef.current.selectedElements,
- tracks: sceneTracks,
- });
- if (!moveGroup) {
- return;
- }
-
- moveGroupRef.current = moveGroup;
- newTrackIdsRef.current = moveGroup.members.map(() => generateUUID());
- const dragTimeOffsets: Record<string, number> = {};
- for (const member of moveGroup.members) {
- dragTimeOffsets[member.elementId] = member.timeOffset;
- }
- const {
- snappedTime: initialSnappedTime,
- snapPoint: initialSnapPoint,
- } = getDragSnapResult({
- frameSnappedTime: snappedTime,
- group: moveGroup,
- });
- const verticalDragDirection = getVerticalDragDirection({
- startMouseY: pendingDragRef.current.startMouseY,
- currentMouseY: clientY,
- });
- const anchorDropTarget = getDragDropTarget({
- clientX,
- clientY,
- elementId: pendingDragRef.current.elementId,
- trackId: pendingDragRef.current.trackId,
- tracks: sceneTracks,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- zoomLevel,
- snappedTime: initialSnappedTime,
- verticalDragDirection,
- });
- const nextGroupMoveResult =
- anchorDropTarget != null
- ? resolveGroupDragMove({
- group: moveGroup,
- snappedTime: initialSnappedTime,
- dropTarget: anchorDropTarget,
- })
- : null;
- groupMoveResultRef.current = nextGroupMoveResult;
- setDragDropTarget(
- anchorDropTarget &&
- (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
- ? {
- ...anchorDropTarget,
- isNewTrack: true,
- }
- : null,
- );
- startDrag({
- ...pendingDragRef.current,
- dragElementIds: moveGroup.members.map((member) => member.elementId),
- dragTimeOffsets,
- initialCurrentTime: initialSnappedTime,
- initialCurrentMouseY: clientY,
- });
- onSnapPointChange?.(initialSnapPoint);
- startedDragThisEvent = true;
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- } else {
- return;
- }
- }
-
- if (startedDragThisEvent) {
- return;
- }
-
- if (dragState.elementId && dragState.trackId) {
- const alreadySelected = isElementSelected({
- trackId: dragState.trackId,
- elementId: dragState.elementId,
- });
- if (!alreadySelected) {
- selectElement({
- trackId: dragState.trackId,
- elementId: dragState.elementId,
- });
- }
- }
-
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- const scrollLeft = scrollContainer.scrollLeft;
- const mouseTime = getMouseTimeFromClientX({
- clientX,
- containerRect: scrollContainer.getBoundingClientRect(),
- zoomLevel,
- scrollLeft,
- });
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- const fps = activeProject.settings.fps;
- const frameSnappedTime =
- roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
-
- const moveGroup = moveGroupRef.current;
- const { snappedTime, snapPoint } = getDragSnapResult({
- frameSnappedTime,
- group: moveGroup,
- });
- setDragState((previousDragState) => ({
- ...previousDragState,
- currentTime: snappedTime,
- currentMouseY: clientY,
- }));
- onSnapPointChange?.(snapPoint);
-
- if (dragState.elementId && dragState.trackId) {
- const verticalDragDirection = getVerticalDragDirection({
- startMouseY: dragState.startMouseY,
- currentMouseY: clientY,
- });
- const anchorDropTarget = getDragDropTarget({
- clientX,
- clientY,
- elementId: dragState.elementId,
- trackId: dragState.trackId,
- tracks: sceneTracks,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- zoomLevel,
- snappedTime,
- verticalDragDirection,
- });
- const nextGroupMoveResult =
- moveGroup && anchorDropTarget
- ? resolveGroupDragMove({
- group: moveGroup,
- snappedTime,
- dropTarget: anchorDropTarget,
- })
- : null;
- groupMoveResultRef.current = nextGroupMoveResult;
- setDragDropTarget(
- anchorDropTarget &&
- (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
- ? {
- ...anchorDropTarget,
- isNewTrack: true,
- }
- : null,
- );
- }
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- return () => document.removeEventListener("mousemove", handleMouseMove);
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.elementId,
- dragState.startMouseY,
- dragState.trackId,
- zoomLevel,
- isElementSelected,
- selectElement,
- editor.project,
- timelineRef,
- tracksScrollRef,
- tracksContainerRef,
- headerRef,
- isPendingDrag,
- startDrag,
- getDragSnapResult,
- resolveGroupDragMove,
- sceneTracks,
- onSnapPointChange,
- ]);
-
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
- if (!dragState.elementId || !dragState.trackId) return;
-
- if (mouseDownLocationRef.current) {
- const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
- const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
- if (
- deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
- deltaY <= TIMELINE_DRAG_THRESHOLD_PX
- ) {
- mouseDownLocationRef.current = null;
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
- }
-
- const moveGroup = moveGroupRef.current;
- if (!moveGroup) {
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
-
- const groupMoveResult = groupMoveResultRef.current;
- if (!groupMoveResult) {
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
-
- const didMove = groupMoveResult.moves.some((move) => {
- const currentMember = moveGroup.members.find(
- (member) => member.elementId === move.elementId,
- );
- const originalStartTime =
- dragState.startElementTime + (currentMember?.timeOffset ?? 0);
- return (
- currentMember?.trackId !== move.targetTrackId ||
- originalStartTime !== move.newStartTime
- );
- });
- if (!didMove && groupMoveResult.createTracks.length === 0) {
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
-
- editor.timeline.moveElements({
- moves: groupMoveResult.moves,
- createTracks: groupMoveResult.createTracks,
- });
- endDrag();
- onSnapPointChange?.(null);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [
- dragState.isDragging,
- dragState.elementId,
- dragState.startElementTime,
- dragState.trackId,
- endDrag,
- onSnapPointChange,
- editor.timeline,
- ]);
-
- 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 handleElementMouseDown = useCallback(
- ({
- event,
- element,
- track,
- }: {
- event: ReactMouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- }) => {
- const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
-
- // right-click: don't stop propagation so ContextMenu can open
- if (isRightClick) {
- const alreadySelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
- if (!alreadySelected) {
- handleSelectionClick({
- trackId: track.id,
- elementId: element.id,
- isMultiKey: false,
- });
- }
- return;
- }
-
- event.stopPropagation();
- mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
-
- const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
-
- if (isMultiSelect) {
- handleSelectionClick({
- trackId: track.id,
- elementId: element.id,
- isMultiKey: true,
- });
- }
-
- const clickOffsetTime = getClickOffsetTime({
- clientX: event.clientX,
- elementRect: event.currentTarget.getBoundingClientRect(),
- zoomLevel,
- });
- const elementRef = {
- trackId: track.id,
- elementId: element.id,
- };
- const pendingSelectedElements = isElementSelected(elementRef)
- ? selectedElements
- : [elementRef];
- pendingDragRef.current = {
- elementId: element.id,
- trackId: track.id,
- selectedElements: pendingSelectedElements,
- startMouseX: event.clientX,
- startMouseY: event.clientY,
- startElementTime: element.startTime,
- clickOffsetTime,
- };
- setIsPendingDrag(true);
- },
- [zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
- );
-
- const handleElementClick = useCallback(
- ({
- event,
- element,
- track,
- }: {
- event: ReactMouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- }) => {
- event.stopPropagation();
-
- if (mouseDownLocationRef.current) {
- const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
- const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
- if (
- deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
- deltaY > TIMELINE_DRAG_THRESHOLD_PX
- ) {
- mouseDownLocationRef.current = null;
- return;
- }
- }
-
- // modifier keys already handled in mousedown
- if (event.metaKey || event.ctrlKey || event.shiftKey) return;
-
- const alreadySelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
- if (!alreadySelected || selectedElements.length > 1) {
- selectElement({ trackId: track.id, elementId: element.id });
- return;
- }
-
- editor.selection.clearKeyframeSelection();
- },
- [editor.selection, isElementSelected, selectElement, selectedElements],
- );
-
- return {
- dragState,
- dragDropTarget,
- handleElementMouseDown,
- handleElementClick,
- lastMouseXRef,
- };
-}
diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts
deleted file mode 100644
index 42187b7f..00000000
--- a/apps/web/src/hooks/timeline/element/use-element-selection.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { useCallback } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import type { ElementRef } from "@/lib/timeline/types";
-
-export function useElementSelection() {
- const editor = useEditor();
- const selectedElements = useEditor((e) => e.selection.getSelectedElements());
-
- const isElementSelected = useCallback(
- ({ trackId, elementId }: ElementRef) =>
- selectedElements.some(
- (element) =>
- element.trackId === trackId && element.elementId === elementId,
- ),
- [selectedElements],
- );
-
- const selectElement = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- editor.selection.setSelectedElements({
- elements: [{ trackId, elementId }],
- });
- },
- [editor],
- );
-
- const addElementToSelection = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- const alreadySelected = selectedElements.some(
- (element) =>
- element.trackId === trackId && element.elementId === elementId,
- );
- if (alreadySelected) return;
-
- editor.selection.setSelectedElements({
- elements: [...selectedElements, { trackId, elementId }],
- });
- },
- [selectedElements, editor],
- );
-
- const removeElementFromSelection = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- editor.selection.setSelectedElements({
- elements: selectedElements.filter(
- (element) =>
- !(element.trackId === trackId && element.elementId === elementId),
- ),
- });
- },
- [selectedElements, editor],
- );
-
- const toggleElementSelection = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- const alreadySelected = selectedElements.some(
- (element) =>
- element.trackId === trackId && element.elementId === elementId,
- );
-
- if (alreadySelected) {
- removeElementFromSelection({ trackId, elementId });
- } else {
- addElementToSelection({ trackId, elementId });
- }
- },
- [selectedElements, addElementToSelection, removeElementFromSelection],
- );
-
- const clearElementSelection = useCallback(() => {
- editor.selection.clearSelection();
- }, [editor]);
-
- const setElementSelection = useCallback(
- ({ elements }: { elements: ElementRef[] }) => {
- editor.selection.setSelectedElements({ elements });
- },
- [editor],
- );
-
-
- /**
- * Merges elements into the current selection, deduplicating by identity.
- * Used for additive box-select where the pre-drag selection is preserved.
- */
- const mergeElementsIntoSelection = useCallback(
- ({ elements }: { elements: ElementRef[] }) => {
- const merged = [
- ...selectedElements.filter(
- (selectedElement) =>
- !elements.some(
- (element) =>
- element.trackId === selectedElement.trackId &&
- element.elementId === selectedElement.elementId,
- ),
- ),
- ...elements,
- ];
- editor.selection.setSelectedElements({ elements: merged });
- },
- [selectedElements, editor],
- );
-
-
- /**
- * Handles click interaction on an element.
- * - Regular click: select only this element
- * - Multi-key click (Ctrl/Cmd): toggle this element in selection
- */
- const handleElementClick = useCallback(
- ({
- trackId,
- elementId,
- isMultiKey,
- }: ElementRef & { isMultiKey: boolean }) => {
- if (isMultiKey) {
- toggleElementSelection({ trackId, elementId });
- } else {
- selectElement({ trackId, elementId });
- }
- },
- [toggleElementSelection, selectElement],
- );
-
- return {
- selectedElements,
- isElementSelected,
- selectElement,
- setElementSelection,
- mergeElementsIntoSelection,
- addElementToSelection,
- removeElementFromSelection,
- toggleElementSelection,
- clearElementSelection,
- handleElementClick,
- };
-}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts b/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts
deleted file mode 100644
index 0fc46372..00000000
--- a/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-import { useCallback, useRef, useMemo } from "react";
-import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
-import {
- useKeyframeSelection,
- getSelectedKeyframeId,
-} from "./use-keyframe-selection";
-import type {
- SelectedKeyframeRef,
- ElementKeyframe,
-} from "@/lib/animation/types";
-import type { ExpandedRow } from "@/components/editor/panels/timeline/expanded-layout";
-import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import {
- KEYFRAME_LANE_HEIGHT_PX,
- KEYFRAME_DIAMOND_SIZE_PX,
-} from "@/components/editor/panels/timeline/layout";
-
-export function useKeyframeBoxSelect({
- trackId,
- elementId,
- rows,
- keyframes,
- displayedStartTime,
- zoomLevel,
- elementLeft,
-}: {
- trackId: string;
- elementId: string;
- rows: ExpandedRow[];
- keyframes: ElementKeyframe[];
- displayedStartTime: number;
- zoomLevel: number;
- elementLeft: number;
-}) {
- const {
- selectedKeyframes,
- keyframeSelectionAnchor,
- setKeyframeSelection,
- clearKeyframeSelection,
- } = useKeyframeSelection();
-
- const containerRef = useRef<HTMLDivElement>(null);
- const initialKeyframesRef = useRef<SelectedKeyframeRef[]>([]);
-
- const keyframeEntries = useMemo(() => {
- const entries: Array<{
- id: string;
- ref: SelectedKeyframeRef;
- rowIndex: number;
- offsetPx: number;
- }> = [];
-
- for (const kf of keyframes) {
- const rowIndex = rows.findIndex(
- (r) => r.propertyPath === kf.propertyPath,
- );
- if (rowIndex === -1) continue;
-
- const ref: SelectedKeyframeRef = {
- trackId,
- elementId,
- propertyPath: kf.propertyPath,
- keyframeId: kf.id,
- };
-
- const kfLeft = timelineTimeToSnappedPixels({
- time: displayedStartTime + kf.time,
- zoomLevel,
- });
-
- entries.push({
- id: getSelectedKeyframeId({ keyframe: ref }),
- ref,
- rowIndex,
- offsetPx: kfLeft - elementLeft,
- });
- }
-
- return entries;
- }, [
- keyframes,
- rows,
- trackId,
- elementId,
- displayedStartTime,
- zoomLevel,
- elementLeft,
- ]);
-
- const idToRefMap = useMemo(() => {
- const map = new Map<string, SelectedKeyframeRef>();
- for (const entry of keyframeEntries) {
- map.set(entry.id, entry.ref);
- }
- return map;
- }, [keyframeEntries]);
-
- const selectedIds = useMemo(
- () => selectedKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
- [selectedKeyframes],
- );
-
- const anchorId = useMemo(
- () =>
- keyframeSelectionAnchor
- ? getSelectedKeyframeId({ keyframe: keyframeSelectionAnchor })
- : null,
- [keyframeSelectionAnchor],
- );
-
- const resolveIntersections = useCallback(
- ({
- startPos,
- currentPos,
- }: {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- }) => {
- const container = containerRef.current;
- if (!container) return [];
-
- const containerRect = container.getBoundingClientRect();
-
- const sx = startPos.x - containerRect.left;
- const sy = startPos.y - containerRect.top;
- const cx = currentPos.x - containerRect.left;
- const cy = currentPos.y - containerRect.top;
-
- const selLeft = Math.min(sx, cx);
- const selTop = Math.min(sy, cy);
- const selRight = Math.max(sx, cx);
- const selBottom = Math.max(sy, cy);
-
- const halfHit = KEYFRAME_DIAMOND_SIZE_PX / 2;
-
- return keyframeEntries
- .filter((entry) => {
- const kfX = entry.offsetPx;
- const kfY =
- entry.rowIndex * KEYFRAME_LANE_HEIGHT_PX +
- KEYFRAME_LANE_HEIGHT_PX / 2;
-
- return !(
- kfX + halfHit < selLeft ||
- kfX - halfHit > selRight ||
- kfY + halfHit < selTop ||
- kfY - halfHit > selBottom
- );
- })
- .map((entry) => entry.id);
- },
- [keyframeEntries],
- );
-
- const onSelectionChange = useCallback(
- ({
- intersectedIds,
- isAdditive,
- }: {
- intersectedIds: string[];
- initialSelectedIds: string[];
- initialAnchorId: string | null;
- isAdditive: boolean;
- }) => {
- const intersectedRefs = intersectedIds
- .map((id) => idToRefMap.get(id))
- .filter((ref): ref is SelectedKeyframeRef => ref != null);
-
- if (isAdditive) {
- setKeyframeSelection({
- keyframes: [
- ...initialKeyframesRef.current,
- ...intersectedRefs,
- ],
- });
- } else {
- setKeyframeSelection({ keyframes: intersectedRefs });
- }
- },
- [idToRefMap, setKeyframeSelection],
- );
-
- const {
- selectionBox,
- handleMouseDown: boxSelectMouseDown,
- isSelecting,
- shouldIgnoreClick,
- } = useBoxSelect<string>({
- containerRef,
- resolveIntersections,
- selectedIds,
- anchorId,
- onSelectionChange,
- });
-
- const handleExpandedAreaMouseDown = useCallback(
- (event: React.MouseEvent) => {
- event.stopPropagation();
- initialKeyframesRef.current = selectedKeyframes;
- boxSelectMouseDown(event);
- },
- [boxSelectMouseDown, selectedKeyframes],
- );
-
- const handleExpandedAreaClick = useCallback(
- (event: React.MouseEvent) => {
- event.stopPropagation();
- if (shouldIgnoreClick()) return;
- if (event.metaKey || event.ctrlKey || event.shiftKey) return;
- clearKeyframeSelection();
- },
- [shouldIgnoreClick, clearKeyframeSelection],
- );
-
- return {
- containerRef,
- selectionBox,
- isBoxSelecting: isSelecting,
- handleExpandedAreaMouseDown,
- handleExpandedAreaClick,
- };
-}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
deleted file mode 100644
index 24bff26a..00000000
--- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-import {
- useState,
- useCallback,
- useEffect,
- useRef,
- type MouseEvent as ReactMouseEvent,
-} from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { getKeyframeById } from "@/lib/animation";
-import { useKeyframeSelection } from "./use-keyframe-selection";
-import { roundToFrame, snappedSeekTime } from "opencut-wasm";
-import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
-import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
-import { BatchCommand } from "@/lib/commands";
-import type { SelectedKeyframeRef } from "@/lib/animation/types";
-import type { TimelineElement } from "@/lib/timeline";
-import type { Command } from "@/lib/commands/base-command";
-import { registerCanceller } from "@/lib/cancel-interaction";
-export interface KeyframeDragState {
- isDragging: boolean;
- draggingKeyframeIds: Set<string>;
- deltaTime: number;
-}
-
-const initialDragState: KeyframeDragState = {
- isDragging: false,
- draggingKeyframeIds: new Set(),
- deltaTime: 0,
-};
-
-interface PendingKeyframeDrag {
- keyframeRefs: SelectedKeyframeRef[];
- startMouseX: number;
-}
-
-export function useKeyframeDrag({
- zoomLevel,
- element,
- displayedStartTime,
-}: {
- zoomLevel: number;
- element: TimelineElement;
- displayedStartTime: number;
-}) {
- const editor = useEditor();
- const {
- selectedKeyframes,
- isKeyframeSelected,
- setKeyframeSelection,
- toggleKeyframeSelection,
- selectKeyframeRange,
- } = useKeyframeSelection();
-
- const [dragState, setDragState] =
- useState<KeyframeDragState>(initialDragState);
- const [isPendingDrag, setIsPendingDrag] = useState(false);
-
- const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
- const mouseDownXRef = useRef<number | null>(null);
-
- const activeProject = editor.project.getActive();
- const fps = activeProject.settings.fps;
-
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
-
- const endDrag = useCallback(() => {
- setDragState(initialDragState);
- }, []);
-
- const cancelDrag = useCallback(() => {
- pendingDragRef.current = null;
- mouseDownXRef.current = null;
- setIsPendingDrag(false);
- endDrag();
- }, [endDrag]);
-
- const commitDrag = useCallback(
- ({
- keyframeRefs,
- deltaTime,
- }: {
- keyframeRefs: SelectedKeyframeRef[];
- deltaTime: number;
- }) => {
- const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
- const keyframe = getKeyframeById({
- animations: element.animations,
- propertyPath: keyframeRef.propertyPath,
- keyframeId: keyframeRef.keyframeId,
- });
- if (!keyframe) return [];
- const nextTime = Math.max(
- 0,
- Math.min(element.duration, keyframe.time + deltaTime),
- );
- return [
- new RetimeKeyframeCommand({
- trackId: keyframeRef.trackId,
- elementId: keyframeRef.elementId,
- propertyPath: keyframeRef.propertyPath,
- keyframeId: keyframeRef.keyframeId,
- nextTime,
- }),
- ];
- });
-
- if (commands.length === 1) {
- editor.command.execute({ command: commands[0] });
- } else if (commands.length > 1) {
- editor.command.execute({ command: new BatchCommand(commands) });
- }
- },
- [editor.command, element],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- return registerCanceller({ fn: cancelDrag });
- }, [dragState.isDragging, isPendingDrag, cancelDrag]);
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- const handleMouseMove = ({ clientX }: MouseEvent) => {
- if (isPendingDrag && pendingDragRef.current) {
- const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
- if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
-
- const pending = pendingDragRef.current;
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- setDragState({
- isDragging: true,
- draggingKeyframeIds: new Set(
- pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
- ),
- deltaTime: 0,
- });
- return;
- }
-
- if (!dragState.isDragging) return;
-
- const startX = mouseDownXRef.current ?? clientX;
- const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND);
- const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
-
- setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- return () => document.removeEventListener("mousemove", handleMouseMove);
- }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]);
-
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseUp = () => {
- const draggingRefs = selectedKeyframes.filter(
- (keyframe) =>
- keyframe.elementId === element.id &&
- dragState.draggingKeyframeIds.has(keyframe.keyframeId),
- );
-
- if (draggingRefs.length > 0 && dragState.deltaTime !== 0) {
- commitDrag({
- keyframeRefs: draggingRefs,
- deltaTime: dragState.deltaTime,
- });
- }
-
- endDrag();
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [
- dragState.isDragging,
- dragState.draggingKeyframeIds,
- dragState.deltaTime,
- selectedKeyframes,
- element.id,
- commitDrag,
- endDrag,
- ]);
-
- useEffect(() => {
- if (!isPendingDrag) return;
-
- const handleMouseUp = () => {
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [isPendingDrag]);
-
- const handleKeyframeMouseDown = useCallback(
- ({
- event,
- keyframes,
- }: {
- event: ReactMouseEvent;
- keyframes: SelectedKeyframeRef[];
- }) => {
- event.preventDefault();
- event.stopPropagation();
-
- mouseDownXRef.current = event.clientX;
-
- const anySelected = keyframes.some((keyframe) =>
- isKeyframeSelected({ keyframe }),
- );
-
- const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
- if (!anySelected && !isModifierKey) {
- setKeyframeSelection({ keyframes });
- }
-
- const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
-
- pendingDragRef.current = {
- keyframeRefs: keyframeRefsToTrack,
- startMouseX: event.clientX,
- };
- setIsPendingDrag(true);
- },
- [isKeyframeSelected, selectedKeyframes, setKeyframeSelection],
-);
-
- const handleKeyframeClick = useCallback(
- ({
- event,
- keyframes,
- orderedKeyframes,
- indicatorTime,
- }: {
- event: ReactMouseEvent;
- keyframes: SelectedKeyframeRef[];
- orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
- }) => {
- event.stopPropagation();
-
- const wasDrag =
- mouseDownXRef.current !== null &&
- Math.abs(event.clientX - mouseDownXRef.current) >
- TIMELINE_DRAG_THRESHOLD_PX;
- mouseDownXRef.current = null;
-
- if (wasDrag) return;
-
- const duration = editor.timeline.getTotalDuration();
- const seekTime = snappedSeekTime({ time: displayedStartTime + indicatorTime, duration, rate: fps }) ?? displayedStartTime + indicatorTime;
- editor.playback.seek({ time: seekTime });
-
- if (event.shiftKey) {
- selectKeyframeRange({
- orderedKeyframes,
- targetKeyframes: keyframes,
- isAdditive: event.metaKey || event.ctrlKey,
- });
- return;
- }
-
- toggleKeyframeSelection({
- keyframes,
- isMultiKey: event.metaKey || event.ctrlKey,
- });
- },
- [toggleKeyframeSelection, selectKeyframeRange, editor, displayedStartTime, fps],
- );
-
- const getVisualOffsetPx = useCallback(
- ({
- indicatorTime,
- indicatorOffsetPx,
- isBeingDragged,
- displayedStartTime,
- elementLeft,
- }: {
- indicatorTime: number;
- indicatorOffsetPx: number;
- isBeingDragged: boolean;
- displayedStartTime: number;
- elementLeft: number;
- }): number => {
- if (!isBeingDragged) return indicatorOffsetPx;
- const clampedTime = Math.max(
- 0,
- Math.min(element.duration, indicatorTime + dragState.deltaTime),
- );
- return (
- timelineTimeToSnappedPixels({
- time: displayedStartTime + clampedTime,
- zoomLevel,
- }) - elementLeft
- );
- },
- [dragState.deltaTime, element.duration, zoomLevel],
- );
-
- return {
- keyframeDragState: dragState,
- handleKeyframeMouseDown,
- handleKeyframeClick,
- getVisualOffsetPx,
- };
-}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
deleted file mode 100644
index 2b5a0566..00000000
--- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import { useCallback, useSyncExternalStore } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import type { SelectedKeyframeRef } from "@/lib/animation/types";
-
-export function getSelectedKeyframeId({
- keyframe,
-}: {
- keyframe: SelectedKeyframeRef;
-}): string {
- return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
-}
-
-function mergeUniqueKeyframes({
- keyframes,
-}: {
- keyframes: SelectedKeyframeRef[];
-}): SelectedKeyframeRef[] {
- const keyframesById = new Map<string, SelectedKeyframeRef>();
- for (const keyframe of keyframes) {
- keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
- }
- return [...keyframesById.values()];
-}
-
-export function useKeyframeSelection() {
- const editor = useEditor();
- const selectedKeyframes = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedKeyframes(),
- );
- const keyframeSelectionAnchor = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getKeyframeSelectionAnchor(),
- );
-
- const isKeyframeSelected = useCallback(
- ({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
- const keyframeId = getSelectedKeyframeId({ keyframe });
- return selectedKeyframes.some(
- (selectedKeyframe) =>
- getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
- );
- },
- [selectedKeyframes],
- );
-
- const setKeyframeSelection = useCallback(
- ({
- keyframes,
- anchorKeyframe,
- }: {
- keyframes: SelectedKeyframeRef[];
- anchorKeyframe?: SelectedKeyframeRef;
- }) => {
- const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
- editor.selection.setSelectedKeyframes({
- keyframes: uniqueKeyframes,
- anchorKeyframe:
- anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
- });
- },
- [editor],
- );
-
- const addKeyframesToSelection = useCallback(
- ({
- keyframes,
- anchorKeyframe,
- }: {
- keyframes: SelectedKeyframeRef[];
- anchorKeyframe?: SelectedKeyframeRef;
- }) => {
- const mergedKeyframes = mergeUniqueKeyframes({
- keyframes: [...selectedKeyframes, ...keyframes],
- });
- editor.selection.setSelectedKeyframes({
- keyframes: mergedKeyframes,
- anchorKeyframe:
- anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
- });
- },
- [selectedKeyframes, editor],
- );
-
- const removeKeyframesFromSelection = useCallback(
- ({
- keyframes,
- anchorKeyframe,
- }: {
- keyframes: SelectedKeyframeRef[];
- anchorKeyframe?: SelectedKeyframeRef;
- }) => {
- const keyframeIdsToRemove = new Set(
- keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
- );
- const nextKeyframes = selectedKeyframes.filter(
- (selectedKeyframe) =>
- !keyframeIdsToRemove.has(
- getSelectedKeyframeId({ keyframe: selectedKeyframe }),
- ),
- );
- editor.selection.setSelectedKeyframes({
- keyframes: nextKeyframes,
- anchorKeyframe:
- anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
- });
- },
- [selectedKeyframes, editor],
- );
-
- const clearKeyframeSelection = useCallback(() => {
- editor.selection.clearKeyframeSelection();
- }, [editor]);
-
- const toggleKeyframeSelection = useCallback(
- ({
- keyframes,
- isMultiKey,
- }: {
- keyframes: SelectedKeyframeRef[];
- isMultiKey: boolean;
- }) => {
- const anchorKeyframe = keyframes[0];
- const areAllKeyframesSelected = keyframes.every((keyframe) =>
- isKeyframeSelected({ keyframe }),
- );
- if (!isMultiKey) {
- setKeyframeSelection({ keyframes, anchorKeyframe });
- return;
- }
-
- if (areAllKeyframesSelected) {
- removeKeyframesFromSelection({ keyframes, anchorKeyframe });
- return;
- }
-
- addKeyframesToSelection({ keyframes, anchorKeyframe });
- },
- [
- setKeyframeSelection,
- isKeyframeSelected,
- removeKeyframesFromSelection,
- addKeyframesToSelection,
- ],
- );
-
- const selectKeyframeRange = useCallback(
- ({
- orderedKeyframes,
- targetKeyframes,
- isAdditive,
- }: {
- orderedKeyframes: SelectedKeyframeRef[];
- targetKeyframes: SelectedKeyframeRef[];
- isAdditive: boolean;
- }) => {
- if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
- return;
- }
-
- const anchorKeyframe =
- keyframeSelectionAnchor ??
- selectedKeyframes[selectedKeyframes.length - 1] ??
- targetKeyframes[0];
- if (!anchorKeyframe) {
- return;
- }
-
- const targetKeyframeIds = new Set(
- targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
- );
- const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
- const anchorIndex = orderedKeyframes.findIndex(
- (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
- );
- if (anchorIndex === -1) {
- if (isAdditive) {
- addKeyframesToSelection({
- keyframes: targetKeyframes,
- anchorKeyframe,
- });
- return;
- }
- setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
- return;
- }
-
- const targetIndexes = orderedKeyframes
- .map((keyframe, index) => ({
- keyframeId: getSelectedKeyframeId({ keyframe }),
- index,
- }))
- .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
- .map(({ index }) => index);
- if (targetIndexes.length === 0) {
- return;
- }
-
- const rangeStart = Math.min(anchorIndex, ...targetIndexes);
- const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
- const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
-
- if (isAdditive) {
- addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
- return;
- }
-
- setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
- },
- [
- keyframeSelectionAnchor,
- selectedKeyframes,
- addKeyframesToSelection,
- setKeyframeSelection,
- ],
- );
-
- return {
- selectedKeyframes,
- keyframeSelectionAnchor,
- isKeyframeSelected,
- setKeyframeSelection,
- addKeyframesToSelection,
- removeKeyframesFromSelection,
- clearKeyframeSelection,
- toggleKeyframeSelection,
- selectKeyframeRange,
- };
-}
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
deleted file mode 100644
index 3ff1cb21..00000000
--- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts
+++ /dev/null
@@ -1,278 +0,0 @@
-import {
- useState,
- useCallback,
- useEffect,
- useRef,
- type RefObject,
-} from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
-import { roundToFrame } from "opencut-wasm";
-import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
-import {
- findSnapPoints,
- snapToNearestPoint,
- type SnapPoint,
-} from "@/lib/timeline/snap-utils";
-import type { Bookmark } from "@/lib/timeline";
-
-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.scenes.getActiveScene().tracks;
- const activeScene = editor.scenes.getActiveScene();
- const bookmarks = activeScene?.bookmarks ?? [];
- const playheadTime = editor.playback.getCurrentTime();
- const duration = editor.timeline.getTotalDuration();
-
- 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, 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 <= TIMELINE_DRAG_THRESHOLD_PX &&
- deltaY <= TIMELINE_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 = roundToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), rate: activeProject.settings.fps }) ?? Math.max(0, Math.min(mouseTime, duration));
- 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 = roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? clampedTime;
- 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,
- };
-}
diff --git a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts
deleted file mode 100644
index 509031bb..00000000
--- a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { useEffect, useRef } from "react";
-
-interface UseEdgeAutoScrollParams {
- isActive: boolean;
- getMouseClientX: () => number;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- contentWidth: number;
- edgeThreshold?: number;
- maxScrollSpeed?: number;
-}
-
-export function useEdgeAutoScroll({
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold = 100,
- maxScrollSpeed = 15,
-}: UseEdgeAutoScrollParams): void {
- const rafRef = useRef<number | null>(null);
-
- useEffect(() => {
- if (!isActive) {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- return;
- }
-
- const step = () => {
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) {
- rafRef.current = requestAnimationFrame(step);
- return;
- }
-
- const viewportRect = rulerViewport.getBoundingClientRect();
- const mouseX = getMouseClientX();
- const mouseXRelative = mouseX - viewportRect.left;
-
- const viewportWidth = rulerViewport.clientWidth;
- const intrinsicContentWidth = rulerViewport.scrollWidth;
- const effectiveContentWidth = Math.max(
- contentWidth,
- intrinsicContentWidth,
- );
- const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
-
- let scrollSpeed = 0;
-
- if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
- const edgeDistance = Math.max(0, mouseXRelative);
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = -maxScrollSpeed * intensity;
- } else if (
- mouseXRelative > viewportWidth - edgeThreshold &&
- rulerViewport.scrollLeft < scrollMax
- ) {
- const edgeDistance = Math.max(0, viewportWidth - mouseXRelative);
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = maxScrollSpeed * intensity;
- }
-
- if (scrollSpeed !== 0) {
- const newScrollLeft = Math.max(
- 0,
- Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed),
- );
- rulerViewport.scrollLeft = newScrollLeft;
- tracksViewport.scrollLeft = newScrollLeft;
- }
-
- rafRef.current = requestAnimationFrame(step);
- };
-
- rafRef.current = requestAnimationFrame(step);
-
- return () => {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- };
- }, [
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold,
- maxScrollSpeed,
- ]);
-}
diff --git a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
deleted file mode 100644
index 0b1cf92a..00000000
--- a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { type RefObject, useLayoutEffect, useRef } from "react";
-
-interface UseInitialScrollBottomProps {
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- trackLabelsScrollRef: RefObject<HTMLDivElement | null>;
- onAfterScroll?: () => void;
- /** Defers the scroll until there is at least one track to measure against. */
- isReady: boolean;
-}
-
-/**
- * Scrolls the timeline tracks viewport to the bottom once on mount.
- * useLayoutEffect runs synchronously after React commits the DOM but before
- * the browser paints, so the initial scroll position is never visible.
- */
-export function useInitialScrollBottom({
- tracksScrollRef,
- trackLabelsScrollRef,
- onAfterScroll,
- isReady,
-}: UseInitialScrollBottomProps): void {
- const hasScrolledRef = useRef(false);
-
- useLayoutEffect(() => {
- if (!isReady || hasScrolledRef.current) return;
-
- const viewport = tracksScrollRef.current;
- if (!viewport) return;
-
- const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
- if (maxScrollTop <= 0) return;
-
- viewport.scrollTop = maxScrollTop;
-
- if (trackLabelsScrollRef.current) {
- trackLabelsScrollRef.current.scrollTop = maxScrollTop;
- }
-
- onAfterScroll?.();
- hasScrolledRef.current = true;
- }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]);
-}
diff --git a/apps/web/src/hooks/timeline/use-scroll-position.ts b/apps/web/src/hooks/timeline/use-scroll-position.ts
deleted file mode 100644
index ad2c6374..00000000
--- a/apps/web/src/hooks/timeline/use-scroll-position.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { useEffect, useState, useRef } from "react";
-
-interface UseScrollPositionReturn {
- scrollLeft: number;
- viewportWidth: number;
-}
-
-export function useScrollPosition({
- scrollRef,
-}: {
- scrollRef: React.RefObject<HTMLElement | null>;
-}): UseScrollPositionReturn {
- const [scrollLeft, setScrollLeft] = useState(0);
- const [viewportWidth, setViewportWidth] = useState(0);
- const rafIdRef = useRef<number | null>(null);
-
- useEffect(() => {
- const scrollElement = scrollRef.current;
- if (!scrollElement) return;
-
- const updatePosition = () => {
- if (rafIdRef.current !== null) {
- cancelAnimationFrame(rafIdRef.current);
- }
-
- rafIdRef.current = requestAnimationFrame(() => {
- setScrollLeft(scrollElement.scrollLeft);
- setViewportWidth(scrollElement.clientWidth);
- rafIdRef.current = null;
- });
- };
-
- const resizeObserver = new ResizeObserver(() => {
- updatePosition();
- });
-
- updatePosition();
-
- scrollElement.addEventListener("scroll", updatePosition, { passive: true });
- resizeObserver.observe(scrollElement);
-
- return () => {
- scrollElement.removeEventListener("scroll", updatePosition);
- resizeObserver.disconnect();
- if (rafIdRef.current !== null) {
- cancelAnimationFrame(rafIdRef.current);
- }
- };
- }, [scrollRef]);
-
- return { scrollLeft, viewportWidth };
-}
diff --git a/apps/web/src/hooks/timeline/use-scroll-sync.ts b/apps/web/src/hooks/timeline/use-scroll-sync.ts
deleted file mode 100644
index e147c106..00000000
--- a/apps/web/src/hooks/timeline/use-scroll-sync.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import { useEffect, useRef } from "react";
-
-interface UseScrollSyncProps {
- rulerScrollRef?: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- trackLabelsScrollRef?: React.RefObject<HTMLDivElement | null>;
- bookmarksScrollRef?: React.RefObject<HTMLDivElement | null>;
-}
-
-export function useScrollSync({
- tracksScrollRef,
- rulerScrollRef,
- trackLabelsScrollRef,
- bookmarksScrollRef,
-}: UseScrollSyncProps) {
- const isUpdatingRef = useRef(false);
- const lastRulerSync = useRef(0);
- const lastTracksSync = useRef(0);
- const lastVerticalSync = useRef(0);
- const lastBookmarksSync = useRef(0);
-
- useEffect(() => {
- const rulerViewport = rulerScrollRef?.current ?? null;
- const tracksViewport = tracksScrollRef.current;
- const trackLabelsViewport = trackLabelsScrollRef?.current;
- const bookmarksViewport = bookmarksScrollRef?.current;
- let handleBookmarksScroll: (() => void) | null = null;
-
- if (!tracksViewport) return;
-
- const syncScrollLeft = ({
- target,
- scrollLeft,
- }: {
- target: HTMLDivElement | null | undefined;
- scrollLeft: number;
- }) => {
- if (target) {
- target.scrollLeft = scrollLeft;
- }
- };
-
- const handleRulerScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
- lastRulerSync.current = now;
- isUpdatingRef.current = true;
- tracksViewport.scrollLeft = rulerViewport?.scrollLeft ?? 0;
- syncScrollLeft({
- target: bookmarksViewport,
- scrollLeft: rulerViewport?.scrollLeft ?? 0,
- });
- isUpdatingRef.current = false;
- };
-
- const handleTracksScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
- lastTracksSync.current = now;
- isUpdatingRef.current = true;
- syncScrollLeft({
- target: rulerViewport,
- scrollLeft: tracksViewport.scrollLeft,
- });
- syncScrollLeft({
- target: bookmarksViewport,
- scrollLeft: tracksViewport.scrollLeft,
- });
- isUpdatingRef.current = false;
- };
-
- if (rulerViewport && rulerViewport !== tracksViewport) {
- rulerViewport.addEventListener("scroll", handleRulerScroll);
- }
- if (tracksViewport !== rulerViewport) {
- tracksViewport.addEventListener("scroll", handleTracksScroll);
- }
-
- if (bookmarksViewport) {
- handleBookmarksScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastBookmarksSync.current < 16)
- return;
- lastBookmarksSync.current = now;
- isUpdatingRef.current = true;
- const nextScrollLeft = bookmarksViewport.scrollLeft;
- tracksViewport.scrollLeft = nextScrollLeft;
- syncScrollLeft({
- target: rulerViewport,
- scrollLeft: nextScrollLeft,
- });
- isUpdatingRef.current = false;
- };
-
- if (bookmarksViewport !== tracksViewport) {
- bookmarksViewport.addEventListener("scroll", handleBookmarksScroll);
- }
- }
-
- if (trackLabelsViewport) {
- const handleTrackLabelsScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
- return;
- lastVerticalSync.current = now;
- isUpdatingRef.current = true;
- tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
- isUpdatingRef.current = false;
- };
-
- const handleTracksVerticalScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
- return;
- lastVerticalSync.current = now;
- isUpdatingRef.current = true;
- trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
- isUpdatingRef.current = false;
- };
-
- trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
- tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
-
- return () => {
- if (rulerViewport && rulerViewport !== tracksViewport) {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- }
- if (tracksViewport !== rulerViewport) {
- tracksViewport.removeEventListener("scroll", handleTracksScroll);
- }
- if (
- bookmarksViewport &&
- bookmarksViewport !== tracksViewport &&
- handleBookmarksScroll
- ) {
- bookmarksViewport.removeEventListener(
- "scroll",
- handleBookmarksScroll,
- );
- }
- trackLabelsViewport.removeEventListener(
- "scroll",
- handleTrackLabelsScroll,
- );
- tracksViewport.removeEventListener(
- "scroll",
- handleTracksVerticalScroll,
- );
- };
- }
-
- return () => {
- if (rulerViewport && rulerViewport !== tracksViewport) {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- }
- if (tracksViewport !== rulerViewport) {
- tracksViewport.removeEventListener("scroll", handleTracksScroll);
- }
- if (
- bookmarksViewport &&
- bookmarksViewport !== tracksViewport &&
- handleBookmarksScroll
- ) {
- bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
- }
- };
- }, [
- rulerScrollRef,
- tracksScrollRef,
- trackLabelsScrollRef,
- bookmarksScrollRef,
- ]);
-}
diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
deleted file mode 100644
index b941dcb5..00000000
--- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { useEffect, useState } from "react";
-import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/components/editor/panels/timeline/layout";
-interface UseSnapIndicatorPositionParams {
- snapPoint: { time: number } | null;
- zoomLevel: number;
- timelineRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
-}
-
-interface SnapIndicatorPosition {
- leftPosition: number;
- topPosition: number;
- height: number;
-}
-
-export function useSnapIndicatorPosition({
- snapPoint,
- zoomLevel,
- timelineRef,
- tracksScrollRef,
-}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
- const [scrollLeft, setScrollLeft] = useState(0);
-
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
-
- if (!tracksViewport) return;
-
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
-
- setScrollLeft(tracksViewport.scrollLeft);
-
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
-
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
- const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
-
- const timelinePosition = timelineTimeToSnappedPixels({
- time: snapPoint?.time ?? 0,
- zoomLevel,
- });
- const leftPosition =
- TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX + timelinePosition - scrollLeft;
-
- return {
- leftPosition,
- topPosition: 0,
- height: totalHeight,
- };
-}
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
deleted file mode 100644
index 25c24b8b..00000000
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ /dev/null
@@ -1,648 +0,0 @@
-import { useState, useCallback, type RefObject } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { processMediaAssets } from "@/lib/media/processing";
-import { toast } from "sonner";
-import { showMediaUploadToast } from "@/lib/media/upload-toast";
-import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { roundToFrame } from "opencut-wasm";
-import {
- buildTextElement,
- buildGraphicElement,
- buildStickerElement,
- buildElementFromMedia,
- buildEffectElement,
-} from "@/lib/timeline/element-utils";
-import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
-import { BatchCommand } from "@/lib/commands";
-import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
-import { getDragData, hasDragData } from "@/lib/drag-data";
-import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
-import type {
- MediaDragData,
- GraphicDragData,
- StickerDragData,
- EffectDragData,
-} from "@/lib/timeline/drag";
-
-interface UseTimelineDragDropProps {
- containerRef: RefObject<HTMLDivElement | null>;
- headerRef?: RefObject<HTMLElement | null>;
- tracksScrollRef?: RefObject<HTMLDivElement | null>;
- zoomLevel: number;
-}
-
-export function useTimelineDragDrop({
- containerRef,
- headerRef,
- tracksScrollRef,
- zoomLevel,
-}: UseTimelineDragDropProps) {
- const editor = useEditor();
- const [isDragOver, setIsDragOver] = useState(false);
- const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
- const [dragElementType, setElementType] = useState<ElementType | null>(null);
-
- const getSnappedTime = useCallback(
- ({ time }: { time: number }) => {
- const projectFps = editor.project.getActive().settings.fps;
- return roundToFrame({ time, rate: projectFps }) ?? time;
- },
- [editor],
- );
-
- const getElementType = useCallback(
- ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
- const dragData = getDragData({ dataTransfer });
- if (!dragData) return null;
-
- if (dragData.type === "text") return "text";
- if (dragData.type === "graphic") return "graphic";
- if (dragData.type === "sticker") return "sticker";
- if (dragData.type === "effect") return "effect";
- if (dragData.type === "media") {
- return dragData.mediaType;
- }
- return null;
- },
- [],
- );
-
- const getElementDuration = useCallback(
- ({
- elementType,
- mediaId,
- }: {
- elementType: ElementType;
- mediaId?: string;
- }): number => {
- if (
- elementType === "text" ||
- elementType === "graphic" ||
- elementType === "sticker" ||
- elementType === "effect"
- ) {
- return DEFAULT_NEW_ELEMENT_DURATION;
- }
- if (mediaId) {
- const mediaAssets = editor.media.getAssets();
- const media = mediaAssets.find((m) => m.id === mediaId);
- return media?.duration != null
- ? Math.round(media.duration * TICKS_PER_SECOND)
- : DEFAULT_NEW_ELEMENT_DURATION;
- }
- return DEFAULT_NEW_ELEMENT_DURATION;
- },
- [editor],
- );
-
- const handleDragEnter = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
- const hasFiles = e.dataTransfer.types.includes("Files");
- if (!hasAsset && !hasFiles) return;
- setIsDragOver(true);
- }, []);
-
- const handleDragOver = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault();
-
- const scrollContainer = tracksScrollRef?.current;
- const referenceRect =
- scrollContainer?.getBoundingClientRect() ??
- containerRef.current?.getBoundingClientRect();
- if (!referenceRect) return;
-
- const headerHeight =
- headerRef?.current?.getBoundingClientRect().height ?? 0;
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
- const hasFiles = e.dataTransfer.types.includes("Files");
- const isExternal =
- hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
-
- const elementType = getElementType({ dataTransfer: e.dataTransfer });
-
- if (!elementType && hasFiles && isExternal) {
- setDropTarget(null);
- setElementType(null);
- return;
- }
-
- if (!elementType) return;
-
- setElementType(elementType);
-
- const dragData = getDragData({ dataTransfer: e.dataTransfer });
- const duration = getElementDuration({
- elementType,
- mediaId: dragData?.type === "media" ? dragData.id : undefined,
- });
-
- const mouseX = e.clientX - referenceRect.left + scrollLeft;
- const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
-
- const targetElementTypes =
- dragData?.type === "effect"
- ? (dragData as EffectDragData).targetElementTypes
- : dragData?.type === "media"
- ? (dragData as MediaDragData).targetElementTypes
- : undefined;
-
- const sceneTracks = editor.scenes.getActiveScene().tracks;
- const currentTime = editor.playback.getCurrentTime();
- const target = computeDropTarget({
- elementType,
- mouseX,
- mouseY,
- tracks: sceneTracks,
- playheadTime: currentTime,
- isExternalDrop: isExternal,
- elementDuration: duration,
- pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
- zoomLevel,
- targetElementTypes,
- });
-
- target.xPosition = getSnappedTime({ time: target.xPosition });
-
- setDropTarget(target);
- e.dataTransfer.dropEffect = "copy";
- },
- [
- containerRef,
- headerRef,
- tracksScrollRef,
- zoomLevel,
- getElementType,
- getElementDuration,
- getSnappedTime,
- editor,
- ],
- );
-
- const handleDragLeave = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault();
- const rect = containerRef.current?.getBoundingClientRect();
- if (rect) {
- const { clientX, clientY } = e;
- if (
- clientX < rect.left ||
- clientX > rect.right ||
- clientY < rect.top ||
- clientY > rect.bottom
- ) {
- setIsDragOver(false);
- setDropTarget(null);
- setElementType(null);
- }
- }
- },
- [containerRef],
- );
-
- const executeTextDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: { name?: string; content?: string };
- }) => {
- const element = buildTextElement({
- raw: {
- name: dragData.name ?? "",
- content: dragData.content ?? "",
- },
- startTime: target.xPosition,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = [
- ...editor.scenes.getActiveScene().tracks.overlay,
- editor.scenes.getActiveScene().tracks.main,
- ...editor.scenes.getActiveScene().tracks.audio,
- ];
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeStickerDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: StickerDragData;
- }) => {
- const element = buildStickerElement({
- stickerId: dragData.stickerId,
- name: dragData.name,
- startTime: target.xPosition,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = [
- ...editor.scenes.getActiveScene().tracks.overlay,
- editor.scenes.getActiveScene().tracks.main,
- ...editor.scenes.getActiveScene().tracks.audio,
- ];
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeGraphicDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: GraphicDragData;
- }) => {
- const element = buildGraphicElement({
- definitionId: dragData.definitionId,
- name: dragData.name,
- startTime: target.xPosition,
- params: dragData.params,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = [
- ...editor.scenes.getActiveScene().tracks.overlay,
- editor.scenes.getActiveScene().tracks.main,
- ...editor.scenes.getActiveScene().tracks.audio,
- ];
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeMediaDrop = useCallback(
- ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
- if (target.targetElement) {
- toast.info("Replace media source is coming soon!");
- return;
- }
-
- const mediaAssets = editor.media.getAssets();
- const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
- if (!mediaAsset) return;
-
- const trackType: TrackType =
- dragData.mediaType === "audio" ? "audio" : "video";
-
- const duration =
- mediaAsset.duration != null
- ? Math.round(mediaAsset.duration * TICKS_PER_SECOND)
- : DEFAULT_NEW_ELEMENT_DURATION;
- const element = buildElementFromMedia({
- mediaId: mediaAsset.id,
- mediaType: mediaAsset.type,
- name: mediaAsset.name,
- duration,
- startTime: target.xPosition,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = [
- ...editor.scenes.getActiveScene().tracks.overlay,
- editor.scenes.getActiveScene().tracks.main,
- ...editor.scenes.getActiveScene().tracks.audio,
- ];
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeEffectDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: EffectDragData;
- }) => {
- if (target.targetElement) {
- editor.timeline.addClipEffect({
- trackId: target.targetElement.trackId,
- elementId: target.targetElement.elementId,
- effectType: dragData.effectType,
- });
- return;
- }
-
- const tracks = [
- ...editor.scenes.getActiveScene().tracks.overlay,
- editor.scenes.getActiveScene().tracks.main,
- ...editor.scenes.getActiveScene().tracks.audio,
- ];
- const effectTrack = tracks.find((t) => t.type === "effect");
- let trackId: string;
-
- if (effectTrack) {
- trackId = effectTrack.id;
- } else if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element: buildEffectElement({
- effectType: dragData.effectType,
- startTime: target.xPosition,
- }),
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- } else {
- const track = tracks[target.trackIndex];
- if (!track || track.type !== "effect") return;
- trackId = track.id;
- }
-
- const element = buildEffectElement({
- effectType: dragData.effectType,
- startTime: target.xPosition,
- });
-
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element,
- });
- },
- [editor],
- );
-
- const executeFileDrop = useCallback(
- async ({
- files,
- mouseX,
- mouseY,
- }: {
- files: File[];
- mouseX: number;
- mouseY: number;
- }) => {
- const activeProject = editor.project.getActiveOrNull();
- if (!activeProject) return;
-
- await showMediaUploadToast({
- filesCount: files.length,
- promise: async () => {
- const processedAssets = await processMediaAssets({ files });
- const projectId = activeProject.metadata.id;
-
- for (const asset of processedAssets) {
- const createdAsset = await editor.media.addMediaAsset({
- projectId,
- asset,
- });
- if (!createdAsset) continue;
-
- const duration =
- createdAsset.duration != null
- ? Math.round(createdAsset.duration * TICKS_PER_SECOND)
- : DEFAULT_NEW_ELEMENT_DURATION;
- const sceneTracks = editor.scenes.getActiveScene().tracks;
- const currentTime = editor.playback.getCurrentTime();
- const reuseMainTrackId =
- createdAsset.type !== "audio" &&
- sceneTracks.overlay.length === 0 &&
- sceneTracks.audio.length === 0 &&
- sceneTracks.main.elements.length === 0
- ? sceneTracks.main.id
- : null;
- const dropTarget = reuseMainTrackId
- ? null
- : computeDropTarget({
- elementType: createdAsset.type,
- mouseX,
- mouseY,
- tracks: sceneTracks,
- playheadTime: currentTime,
- isExternalDrop: true,
- elementDuration: duration,
- pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
- zoomLevel,
- });
-
- const trackType: TrackType =
- createdAsset.type === "audio" ? "audio" : "video";
-
- const startTime = dropTarget?.xPosition ?? currentTime;
- const element = buildElementFromMedia({
- mediaId: createdAsset.id,
- mediaType: createdAsset.type,
- name: createdAsset.name,
- duration,
- startTime,
- });
-
- if (reuseMainTrackId) {
- editor.command.execute({
- command: new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: reuseMainTrackId },
- }),
- });
- } else {
- if (!dropTarget) continue;
- if (dropTarget.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(
- trackType,
- dropTarget.trackIndex,
- );
- editor.command.execute({
- command: new BatchCommand([
- addTrackCmd,
- new InsertElementCommand({
- element,
- placement: {
- mode: "explicit",
- trackId: addTrackCmd.getTrackId(),
- },
- }),
- ]),
- });
- } else {
- const trackId = [
- ...sceneTracks.overlay,
- sceneTracks.main,
- ...sceneTracks.audio,
- ][dropTarget.trackIndex]?.id;
- if (!trackId) continue;
- editor.command.execute({
- command: new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId },
- }),
- });
- }
- }
- }
-
- return {
- uploadedCount: processedAssets.length,
- assetNames: processedAssets.map((asset) => asset.name),
- };
- },
- });
- },
- [editor, zoomLevel],
- );
-
- const handleDrop = useCallback(
- async (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
- const hasFiles = e.dataTransfer.files?.length > 0;
-
- if (!hasAsset && !hasFiles) return;
-
- const currentTarget = dropTarget;
- setIsDragOver(false);
- setDropTarget(null);
- setElementType(null);
-
- try {
- if (hasAsset) {
- if (!currentTarget) return;
- const dragData = getDragData({ dataTransfer: e.dataTransfer });
- if (!dragData) return;
-
- if (dragData.type === "text") {
- executeTextDrop({ target: currentTarget, dragData });
- } else if (dragData.type === "graphic") {
- executeGraphicDrop({
- target: currentTarget,
- dragData: dragData as GraphicDragData,
- });
- } else if (dragData.type === "sticker") {
- executeStickerDrop({ target: currentTarget, dragData });
- } else if (dragData.type === "effect") {
- executeEffectDrop({
- target: currentTarget,
- dragData: dragData as EffectDragData,
- });
- } else {
- executeMediaDrop({ target: currentTarget, dragData });
- }
- } else if (hasFiles) {
- const scrollContainer = tracksScrollRef?.current;
- const referenceRect =
- scrollContainer?.getBoundingClientRect() ??
- containerRef.current?.getBoundingClientRect();
- if (!referenceRect) return;
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
- const mouseX = e.clientX - referenceRect.left + scrollLeft;
- const headerHeight =
- headerRef?.current?.getBoundingClientRect().height ?? 0;
- const mouseY =
- e.clientY - referenceRect.top + scrollTop - headerHeight;
- await executeFileDrop({
- files: Array.from(e.dataTransfer.files),
- mouseX,
- mouseY,
- });
- }
- } catch (err) {
- console.error("Failed to process drop:", err);
- }
- },
- [
- dropTarget,
- executeTextDrop,
- executeGraphicDrop,
- executeStickerDrop,
- executeMediaDrop,
- executeEffectDrop,
- executeFileDrop,
- containerRef,
- headerRef,
- tracksScrollRef,
- ],
- );
-
- return {
- isDragOver,
- dropTarget,
- dragElementType,
- dragProps: {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- },
- };
-}
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
deleted file mode 100644
index 1886222f..00000000
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ /dev/null
@@ -1,290 +0,0 @@
-import { snappedSeekTime } from "opencut-wasm";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { 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 { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
-import {
- getCenteredLineLeft,
- timelineTimeToPixels,
- timelineTimeToSnappedPixels,
-} from "@/lib/timeline";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-
-interface UseTimelinePlayheadProps {
- zoomLevel: number;
- rulerRef: React.RefObject<HTMLDivElement | null>;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- playheadRef?: React.RefObject<HTMLDivElement | null>;
-}
-
-export function useTimelinePlayhead({
- zoomLevel,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
-}: UseTimelinePlayheadProps) {
- const editor = useEditor();
- const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
- const activeProject = editor.project.getActive();
- const duration = editor.timeline.getTotalDuration();
- const isShiftHeldRef = useShiftKey();
-
- const zoomLevelRef = useRef(zoomLevel);
- const durationRef = useRef(duration);
- const isScrubbingRef = useRef(isScrubbing);
- const isPlayingRef = useRef(false);
-
- useEffect(() => {
- zoomLevelRef.current = zoomLevel;
- durationRef.current = duration;
- isScrubbingRef.current = isScrubbing;
- isPlayingRef.current = editor.playback.getIsPlaying();
- }, [zoomLevel, duration, isScrubbing, editor.playback]);
-
- const seek = useCallback(
- ({ time }: { time: number }) => editor.playback.seek({ time }),
- [editor.playback],
- );
-
- const scrubTimeRef = useRef<number | null>(null);
- const isDraggingRulerRef = useRef(false);
- const hasDraggedRulerRef = useRef(false);
- const lastMouseXRef = useRef<number>(0);
-
- const handleScrub = useCallback(
- ({
- event,
- snappingEnabled = true,
- }: {
- event: MouseEvent | React.MouseEvent;
- snappingEnabled?: boolean;
- }) => {
- const ruler = rulerRef.current;
- if (!ruler) return;
- const rulerRect = ruler.getBoundingClientRect();
- const relativeMouseX = event.clientX - rulerRect.left;
-
- const timelineContentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
-
- const clampedMouseX = Math.max(
- 0,
- Math.min(timelineContentWidth, relativeMouseX),
- );
-
- const rawTimeSeconds = Math.max(
- 0,
- Math.min(
- duration / TICKS_PER_SECOND,
- clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
- ),
- );
- const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
-
- const rate = activeProject.settings.fps;
- const frameTime = snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
-
- const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
- const time = (() => {
- if (!shouldSnap) return frameTime;
- const tracks = editor.scenes.getActiveScene().tracks;
- const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime: frameTime,
- bookmarks,
- enablePlayheadSnapping: false,
- });
- const snapResult = snapToNearestPoint({
- targetTime: frameTime,
- snapPoints,
- zoomLevel,
- });
- return snapResult.snapPoint ? snapResult.snappedTime : frameTime;
- })();
-
- scrubTimeRef.current = time;
- seek({ time });
-
- lastMouseXRef.current = event.clientX;
- },
- [
- duration,
- zoomLevel,
- seek,
- rulerRef,
- activeProject.settings.fps,
- isShiftHeldRef,
- editor.scenes,
- editor.timeline,
- ],
- );
-
- const handlePlayheadMouseDown = useCallback(
- ({ event }: { event: React.MouseEvent }) => {
- event.preventDefault();
- event.stopPropagation();
- editor.playback.setScrubbing({ isScrubbing: true });
- handleScrub({ event });
- },
- [handleScrub, editor.playback],
- );
-
- const handleRulerMouseDown = useCallback(
- ({ event }: { event: React.MouseEvent }) => {
- if (event.button !== 0) return;
- if (playheadRef?.current?.contains(event.target as Node)) return;
-
- event.preventDefault();
- isDraggingRulerRef.current = true;
- hasDraggedRulerRef.current = false;
-
- editor.playback.setScrubbing({ isScrubbing: true });
- handleScrub({ event, snappingEnabled: false });
- },
- [handleScrub, playheadRef, editor.playback],
- );
-
- const handlePlayheadMouseDownEvent = useCallback(
- (event: React.MouseEvent) => handlePlayheadMouseDown({ event }),
- [handlePlayheadMouseDown],
- );
-
- const handleRulerMouseDownEvent = useCallback(
- (event: React.MouseEvent) => handleRulerMouseDown({ event }),
- [handleRulerMouseDown],
- );
-
- useEdgeAutoScroll({
- isActive: isScrubbing,
- getMouseClientX: () => lastMouseXRef.current,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }),
- });
-
- useEffect(() => {
- if (!isScrubbing) return;
-
- const handleMouseMove = ({ event }: { event: MouseEvent }) => {
- handleScrub({ event });
- if (isDraggingRulerRef.current) {
- hasDraggedRulerRef.current = true;
- }
- };
-
- const handleMouseUp = ({ event }: { event: MouseEvent }) => {
- editor.playback.setScrubbing({ isScrubbing: false });
- const finalTime = scrubTimeRef.current;
- if (finalTime !== null) {
- seek({ time: finalTime });
- editor.project.setTimelineViewState({
- viewState: {
- zoomLevel,
- scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
- playheadTime: finalTime,
- },
- });
- }
- scrubTimeRef.current = null;
-
- if (isDraggingRulerRef.current) {
- isDraggingRulerRef.current = false;
- if (!hasDraggedRulerRef.current) {
- handleScrub({ event, snappingEnabled: false });
- }
- hasDraggedRulerRef.current = false;
- }
- };
-
- const onMouseMove = (event: MouseEvent) => handleMouseMove({ event });
- const onMouseUp = (event: MouseEvent) => handleMouseUp({ event });
-
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
-
- return () => {
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- };
- }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
-
- const updatePlayheadLeft = useCallback(
- (time: number) => {
- const playheadEl = playheadRef?.current;
- if (!playheadEl) return;
- const centerPosition = timelineTimeToSnappedPixels({
- time,
- zoomLevel: zoomLevelRef.current,
- });
- const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
- const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0;
- playheadEl.style.left = `${leftPosition - scrollLeft}px`;
- },
- [playheadRef, rulerScrollRef],
- );
-
- useEffect(() => {
- const scrollEl = rulerScrollRef.current;
- if (!scrollEl) return;
-
- const handleScroll = () => {
- updatePlayheadLeft(editor.playback.getCurrentTime());
- };
-
- scrollEl.addEventListener("scroll", handleScroll, { passive: true });
- return () => scrollEl.removeEventListener("scroll", handleScroll);
- }, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
-
- useEffect(() => {
- const handlePlaybackUpdate = (e: Event) => {
- const time = (e as CustomEvent<{ time: number }>).detail.time;
- updatePlayheadLeft(time);
-
- if (!isPlayingRef.current || isScrubbingRef.current) return;
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) return;
-
- const playheadPixels = timelineTimeToPixels({
- time,
- zoomLevel: zoomLevelRef.current,
- });
- const viewportWidth = rulerViewport.clientWidth;
- const scrollMinimum = 0;
- const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
-
- const needsScroll =
- playheadPixels < rulerViewport.scrollLeft ||
- playheadPixels > rulerViewport.scrollLeft + viewportWidth;
-
- if (needsScroll) {
- const desiredScroll = Math.max(
- scrollMinimum,
- Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
- );
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- };
-
- const initialTime = editor.playback.getCurrentTime();
- handlePlaybackUpdate({
- detail: { time: initialTime },
- } as CustomEvent<{ time: number }>);
-
- window.addEventListener("playback-update", handlePlaybackUpdate);
- window.addEventListener("playback-seek", handlePlaybackUpdate);
- return () => {
- window.removeEventListener("playback-update", handlePlaybackUpdate);
- window.removeEventListener("playback-seek", handlePlaybackUpdate);
- };
- }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
-
- return {
- handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
- handleRulerMouseDown: handleRulerMouseDownEvent,
- };
-}
diff --git a/apps/web/src/hooks/timeline/use-timeline-resize.ts b/apps/web/src/hooks/timeline/use-timeline-resize.ts
deleted file mode 100644
index f246261a..00000000
--- a/apps/web/src/hooks/timeline/use-timeline-resize.ts
+++ /dev/null
@@ -1,322 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import type { EditorCore } from "@/core";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { useEditor } from "@/hooks/use-editor";
-import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import { useTimelineStore } from "@/stores/timeline-store";
-import {
- computeGroupResize,
- type GroupResizeMember,
- type GroupResizeResult,
- type ResizeSide,
-} from "@/lib/timeline/group-resize";
-import {
- findSnapPoints,
- snapToNearestPoint,
- type SnapPoint,
-} from "@/lib/timeline/snap-utils";
-import type {
- SceneTracks,
- TimelineElement,
- TimelineTrack,
-} from "@/lib/timeline";
-import { isRetimableElement } from "@/lib/timeline";
-import { registerCanceller } from "@/lib/cancel-interaction";
-
-interface ResizeInteractionState {
- side: ResizeSide;
- startX: number;
- members: GroupResizeMember[];
-}
-
-export function useTimelineResize({
- zoomLevel,
- onSnapPointChange,
-}: {
- zoomLevel: number;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
- const { selectedElements } = useElementSelection();
- const [resizeState, setResizeState] = useState<ResizeInteractionState | null>(
- null,
- );
- const latestResultRef = useRef<GroupResizeResult | null>(null);
-
- const cancelResize = useCallback(() => {
- editor.timeline.discardPreview();
- setResizeState(null);
- latestResultRef.current = null;
- onSnapPointChange?.(null);
- }, [editor.timeline, onSnapPointChange]);
-
- useEffect(() => {
- if (!resizeState) {
- return;
- }
-
- return registerCanceller({ fn: cancelResize });
- }, [resizeState, cancelResize]);
-
- const handleResizeStart = useCallback(
- ({
- event,
- element,
- track,
- side,
- }: {
- event: React.MouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- side: ResizeSide;
- }) => {
- event.stopPropagation();
- event.preventDefault();
-
- const elementRef = {
- trackId: track.id,
- elementId: element.id,
- };
- const activeSelection = selectedElements.some(
- (selectedElement) =>
- selectedElement.trackId === track.id &&
- selectedElement.elementId === element.id,
- )
- ? selectedElements
- : [elementRef];
- const members = buildResizeMembers({
- tracks: editor.scenes.getActiveScene().tracks,
- selectedElements: activeSelection,
- });
- if (members.length === 0) {
- return;
- }
-
- editor.timeline.discardPreview();
- latestResultRef.current = null;
- setResizeState({
- side,
- startX: event.clientX,
- members,
- });
- },
- [selectedElements, editor],
- );
-
- useEffect(() => {
- if (!resizeState) {
- return;
- }
-
- const handleMouseMove = ({ clientX }: MouseEvent) => {
- const deltaX = clientX - resizeState.startX;
- const rawDeltaTime = Math.round(
- (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) *
- TICKS_PER_SECOND,
- );
- const snappedDeltaTime = getSnappedResizeDelta({
- editor,
- resizeState,
- rawDeltaTime,
- zoomLevel,
- shouldSnap: snappingEnabled && !isShiftHeldRef.current,
- onSnapPointChange,
- });
- const fps = editor.project.getActive().settings.fps;
- const result = computeGroupResize({
- members: resizeState.members,
- side: resizeState.side,
- deltaTime: snappedDeltaTime.deltaTime,
- fps,
- });
-
- latestResultRef.current = result;
- editor.timeline.previewElements({
- updates: result.updates.map(({ trackId, elementId, patch }) => ({
- trackId,
- elementId,
- updates: patch,
- })),
- });
- };
-
- const handleMouseUp = () => {
- const result = latestResultRef.current;
- editor.timeline.discardPreview();
- if (
- result &&
- hasResizeChanges({ members: resizeState.members, result })
- ) {
- editor.timeline.updateElements({
- updates: result.updates.map(({ trackId, elementId, patch }) => ({
- trackId,
- elementId,
- patch,
- })),
- });
- }
-
- setResizeState(null);
- latestResultRef.current = null;
- onSnapPointChange?.(null);
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- resizeState,
- zoomLevel,
- snappingEnabled,
- isShiftHeldRef,
- editor,
- onSnapPointChange,
- ]);
-
- return {
- isResizing: resizeState !== null,
- handleResizeStart,
- };
-}
-
-function buildResizeMembers({
- tracks,
- selectedElements,
-}: {
- tracks: SceneTracks;
- selectedElements: Array<{ trackId: string; elementId: string }>;
-}): GroupResizeMember[] {
- const selectedElementIds = new Set(
- selectedElements.map((selectedElement) => selectedElement.elementId),
- );
- const trackMap = new Map(
- [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
- track.id,
- track,
- ]),
- );
-
- return selectedElements.flatMap(({ trackId, elementId }) => {
- const track = trackMap.get(trackId);
- const element = track?.elements.find(
- (trackElement) => trackElement.id === elementId,
- );
- if (!track || !element) {
- return [];
- }
-
- const otherElements = track.elements.filter(
- (trackElement) => !selectedElementIds.has(trackElement.id),
- );
- const leftNeighborBound = otherElements
- .filter(
- (trackElement) =>
- trackElement.startTime + trackElement.duration <= element.startTime,
- )
- .reduce((bound, trackElement) => {
- return Math.max(bound, trackElement.startTime + trackElement.duration);
- }, -Infinity);
- const rightNeighborBound = otherElements
- .filter(
- (trackElement) =>
- trackElement.startTime >= element.startTime + element.duration,
- )
- .reduce((bound, trackElement) => {
- return Math.min(bound, trackElement.startTime);
- }, Infinity);
-
- return [
- {
- trackId,
- elementId,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- sourceDuration: element.sourceDuration,
- retime: isRetimableElement(element) ? element.retime : undefined,
- leftNeighborBound,
- rightNeighborBound,
- },
- ];
- });
-}
-
-function getSnappedResizeDelta({
- editor,
- resizeState,
- rawDeltaTime,
- zoomLevel,
- shouldSnap,
- onSnapPointChange,
-}: {
- editor: EditorCore;
- resizeState: ResizeInteractionState;
- rawDeltaTime: number;
- zoomLevel: number;
- shouldSnap: boolean;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}): { deltaTime: number } {
- if (!shouldSnap) {
- onSnapPointChange?.(null);
- return { deltaTime: rawDeltaTime };
- }
-
- const snapPoints = findSnapPoints({
- tracks: editor.scenes.getActiveScene().tracks,
- playheadTime: editor.playback.getCurrentTime(),
- excludeElementIds: new Set(
- resizeState.members.map((member) => member.elementId),
- ),
- });
- let closestSnapPoint: SnapPoint | null = null;
- let closestSnapDistance = Infinity;
- let deltaTime = rawDeltaTime;
-
- for (const member of resizeState.members) {
- const baseEdgeTime =
- resizeState.side === "left"
- ? member.startTime
- : member.startTime + member.duration;
- const snapResult = snapToNearestPoint({
- targetTime: baseEdgeTime + rawDeltaTime,
- snapPoints,
- zoomLevel,
- });
- if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) {
- closestSnapDistance = snapResult.snapDistance;
- closestSnapPoint = snapResult.snapPoint;
- deltaTime = snapResult.snappedTime - baseEdgeTime;
- }
- }
-
- onSnapPointChange?.(closestSnapPoint);
- return { deltaTime };
-}
-
-function hasResizeChanges({
- members,
- result,
-}: {
- members: GroupResizeMember[];
- result: GroupResizeResult;
-}): boolean {
- return result.updates.some((update) => {
- const member = members.find(
- (candidateMember) => candidateMember.elementId === update.elementId,
- );
- return (
- member?.trimStart !== update.patch.trimStart ||
- member?.trimEnd !== update.patch.trimEnd ||
- member?.startTime !== update.patch.startTime ||
- member?.duration !== update.patch.duration
- );
- });
-}
diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts
deleted file mode 100644
index 756ec7ee..00000000
--- a/apps/web/src/hooks/timeline/use-timeline-seek.ts
+++ /dev/null
@@ -1,196 +0,0 @@
-import { useCallback, useRef } from "react";
-import type { MutableRefObject, RefObject } from "react";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { snappedSeekTime } from "opencut-wasm";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { useEditor } from "../use-editor";
-
-interface UseTimelineSeekProps {
- playheadRef: RefObject<HTMLDivElement | null>;
- trackLabelsRef: RefObject<HTMLDivElement | null>;
- rulerScrollRef: RefObject<HTMLDivElement | null>;
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- zoomLevel: number;
- duration: number;
- isSelecting: boolean;
- clearSelectedElements: () => void;
- seek: (time: number) => void;
-}
-
-function resetMouseTracking({
- mouseTrackingRef,
-}: {
- mouseTrackingRef: MutableRefObject<{
- isMouseDown: boolean;
- downX: number;
- downY: number;
- downTime: number;
- }>;
-}) {
- mouseTrackingRef.current = {
- isMouseDown: false,
- downX: 0,
- downY: 0,
- downTime: 0,
- };
-}
-
-function setMouseTracking({
- mouseTrackingRef,
- event,
-}: {
- mouseTrackingRef: MutableRefObject<{
- isMouseDown: boolean;
- downX: number;
- downY: number;
- downTime: number;
- }>;
- event: React.MouseEvent;
-}) {
- mouseTrackingRef.current = {
- isMouseDown: true,
- downX: event.clientX,
- downY: event.clientY,
- downTime: event.timeStamp,
- };
-}
-
-export function useTimelineSeek({
- playheadRef,
- trackLabelsRef,
- rulerScrollRef,
- tracksScrollRef,
- zoomLevel,
- duration,
- isSelecting,
- clearSelectedElements,
- seek,
-}: UseTimelineSeekProps) {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
-
- const mouseTrackingRef = useRef({
- isMouseDown: false,
- downX: 0,
- downY: 0,
- downTime: 0,
- });
-
- const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
- if (event.button !== 0) return;
- setMouseTracking({ mouseTrackingRef, event });
- }, []);
-
- const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
- if (event.button !== 0) return;
- setMouseTracking({ mouseTrackingRef, event });
- }, []);
-
- const shouldProcessTimelineClick = useCallback(
- ({ event }: { event: React.MouseEvent }) => {
- const target = event.target as HTMLElement;
- const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
- const deltaX = Math.abs(event.clientX - downX);
- const deltaY = Math.abs(event.clientY - downY);
- const deltaTime = event.timeStamp - downTime;
- const isPlayhead = !!playheadRef.current?.contains(target);
- const isTrackLabels = !!trackLabelsRef.current?.contains(target);
- const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
-
- if (!isMouseDown) return false;
- if (shouldBlockForDrag) return false;
- if (isSelecting) return false;
- if (isPlayhead) return false;
- if (isTrackLabels) {
- clearSelectedElements();
- return false;
- }
-
- return true;
- },
- [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
- );
-
- const handleTimelineSeek = useCallback(
- ({
- event,
- source,
- }: {
- event: React.MouseEvent;
- source: "ruler" | "tracks";
- }) => {
- const scrollContainer =
- source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
-
- if (!scrollContainer) return;
-
- const rect = scrollContainer.getBoundingClientRect();
- const mouseX = event.clientX - rect.left;
- const scrollLeft = scrollContainer.scrollLeft;
-
- const rawTimeSeconds = Math.max(
- 0,
- Math.min(
- duration / TICKS_PER_SECOND,
- (mouseX + scrollLeft) /
- (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
- ),
- );
- const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
-
- const rate = activeProject?.settings.fps;
- const time = rate ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) : rawTime;
- seek(time);
- editor.project.setTimelineViewState({
- viewState: {
- zoomLevel,
- scrollLeft: scrollContainer.scrollLeft,
- playheadTime: time,
- },
- });
- },
- [
- duration,
- zoomLevel,
- rulerScrollRef,
- tracksScrollRef,
- seek,
- editor,
- activeProject?.settings.fps.numerator,
- activeProject?.settings.fps.denominator,
- ],
- );
-
- const handleTracksClick = useCallback(
- (event: React.MouseEvent) => {
- const shouldProcess = shouldProcessTimelineClick({ event });
- resetMouseTracking({ mouseTrackingRef });
-
- if (shouldProcess) {
- clearSelectedElements();
- handleTimelineSeek({ event, source: "tracks" });
- }
- },
- [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
- );
-
- const handleRulerClick = useCallback(
- (event: React.MouseEvent) => {
- const shouldProcess = shouldProcessTimelineClick({ event });
- resetMouseTracking({ mouseTrackingRef });
-
- if (shouldProcess) {
- clearSelectedElements();
- handleTimelineSeek({ event, source: "ruler" });
- }
- },
- [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
- );
-
- return {
- handleTracksMouseDown,
- handleTracksClick,
- handleRulerMouseDown,
- handleRulerClick,
- };
-}
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
deleted file mode 100644
index 1f79a5b2..00000000
--- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts
+++ /dev/null
@@ -1,297 +0,0 @@
-import {
- type WheelEvent as ReactWheelEvent,
- type RefObject,
- useCallback,
- useEffect,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
-import {
- TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD,
-} from "@/components/editor/panels/timeline/interaction";
-import {
- TIMELINE_ZOOM_MAX,
- TIMELINE_ZOOM_MIN,
-} from "@/lib/timeline/scale";
-import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
-import { useEditor } from "@/hooks/use-editor";
-import { zoomToSlider } from "@/lib/timeline/zoom-utils";
-
-interface UseTimelineZoomProps {
- containerRef: RefObject<HTMLDivElement | null>;
- minZoom?: number;
- initialZoom?: number;
- initialScrollLeft?: number;
- initialPlayheadTime?: number;
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- rulerScrollRef: RefObject<HTMLDivElement | null>;
-}
-
-interface UseTimelineZoomReturn {
- zoomLevel: number;
- setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
- handleWheel: (event: ReactWheelEvent) => void;
- saveScrollPosition: () => void;
-}
-
-export function useTimelineZoom({
- containerRef,
- minZoom = TIMELINE_ZOOM_MIN,
- initialZoom,
- initialScrollLeft,
- initialPlayheadTime,
- tracksScrollRef,
- rulerScrollRef,
-}: UseTimelineZoomProps): UseTimelineZoomReturn {
- const editor = useEditor();
- const hasInitializedRef = useRef(false);
- const hasRestoredPlayheadRef = useRef(false);
- const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
- null,
- );
-
- const [zoomLevel, setZoomLevelRaw] = useState(() => {
- if (initialZoom !== undefined) {
- hasInitializedRef.current = true;
- return Math.max(
- minZoom,
- Math.min(TIMELINE_ZOOM_MAX, initialZoom),
- );
- }
- return minZoom;
- });
- 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)) => {
- const scrollElement = tracksScrollRef.current;
- if (scrollElement) {
- preZoomScrollLeftRef.current = scrollElement.scrollLeft;
- }
- setZoomLevelRaw(updater);
- },
- [tracksScrollRef],
- );
-
- const handleWheel = useCallback(
- (event: ReactWheelEvent) => {
- const isZoomGesture = event.ctrlKey || event.metaKey;
- const isHorizontalScrollGesture =
- event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
-
- if (isHorizontalScrollGesture) {
- return;
- }
-
- // pinch-zoom (ctrl/meta + wheel)
- if (isZoomGesture) {
- const normalizedDelta =
- event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
- const cappedDelta =
- Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
- const zoomFactor = Math.exp(-cappedDelta / 300);
- setZoomLevel((prev) => {
- const nextZoom = Math.max(
- minZoom,
- Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
- );
- return nextZoom;
- });
- return;
- }
- },
- [minZoom, setZoomLevel],
- );
-
- useEffect(() => {
- if (initialZoom !== undefined && !hasInitializedRef.current) {
- hasInitializedRef.current = true;
- setZoomLevel(
- Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)),
- );
- return;
- }
- setZoomLevel((prev) => {
- if (prev < minZoom) {
- return minZoom;
- }
- return prev;
- });
- }, [minZoom, initialZoom, setZoomLevel]);
-
- const wrappedSetZoomLevel = useCallback(
- (zoomLevelOrUpdater: number | ((prev: number) => number)) => {
- setZoomLevel((prev) => {
- const nextZoom =
- typeof zoomLevelOrUpdater === "function"
- ? zoomLevelOrUpdater(prev)
- : zoomLevelOrUpdater;
- const clampedZoom = Math.max(
- minZoom,
- Math.min(TIMELINE_ZOOM_MAX, nextZoom),
- );
- return clampedZoom;
- });
- },
- [minZoom, setZoomLevel],
- );
-
- useLayoutEffect(() => {
- const previousZoom = previousZoomRef.current;
- if (previousZoom === zoomLevel) return;
-
- const scrollElement = tracksScrollRef.current;
- if (!scrollElement) {
- previousZoomRef.current = zoomLevel;
- return;
- }
-
- const currentScrollLeft = preZoomScrollLeftRef.current;
- const playheadTime = editor.playback.getCurrentTime();
- const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
- const previousSliderPercent = zoomToSlider({
- zoomLevel: previousZoom,
- minZoom,
- });
- const isCrossingThresholdUp =
- previousSliderPercent <
- TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
- sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
- const isCrossingThresholdDown =
- previousSliderPercent >=
- TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
- sliderPercent < TIMELINE_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_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
- const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom });
- const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel });
-
- const viewportOffset = playheadPixelsBefore - currentScrollLeft;
- const newScrollLeft = playheadPixelsAfter - viewportOffset;
-
- syncScroll(clampScrollLeft(newScrollLeft));
- } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) {
- syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current));
- isInPlayheadAnchorModeRef.current = false;
- }
-
- previousZoomRef.current = zoomLevel;
-
- editor.project.setTimelineViewState({
- viewState: {
- zoomLevel,
- scrollLeft: scrollElement.scrollLeft,
- playheadTime,
- },
- });
- }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]);
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
- const saveScrollPosition = useCallback(() => {
- if (scrollSaveTimeoutRef.current) {
- clearTimeout(scrollSaveTimeoutRef.current);
- }
- scrollSaveTimeoutRef.current = setTimeout(() => {
- const scrollElement = tracksScrollRef.current;
- if (scrollElement) {
- editor.project.setTimelineViewState({
- viewState: {
- zoomLevel,
- scrollLeft: scrollElement.scrollLeft,
- playheadTime: editor.playback.getCurrentTime(),
- },
- });
- }
- }, 300);
- }, [zoomLevel, editor]);
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable
- useEffect(() => {
- if (initialScrollLeft === undefined) return;
- if (hasRestoredScrollRef.current) return;
- const scrollElement = tracksScrollRef.current;
- if (!scrollElement) return;
-
- const restoreScroll = () => {
- scrollElement.scrollLeft = initialScrollLeft;
- if (rulerScrollRef.current) {
- rulerScrollRef.current.scrollLeft = initialScrollLeft;
- }
- hasRestoredScrollRef.current = true;
- };
-
- if (scrollElement.scrollWidth > 0) {
- restoreScroll();
- } else {
- const observer = new ResizeObserver(() => {
- if (scrollElement.scrollWidth > 0) {
- restoreScroll();
- observer.disconnect();
- }
- });
- observer.observe(scrollElement);
- return () => observer.disconnect();
- }
- }, [initialScrollLeft]);
-
- useEffect(() => {
- if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
- hasRestoredPlayheadRef.current = true;
- editor.playback.seek({ time: initialPlayheadTime });
- }
- }, [initialPlayheadTime, editor]);
-
- // prevent browser zoom in the timeline
- useEffect(() => {
- const preventZoom = (event: WheelEvent) => {
- const isZoomKeyPressed = event.ctrlKey || event.metaKey;
- const isInContainer = containerRef.current?.contains(
- event.target as Node,
- );
- // only check isInContainer, not isInTimeline state - the state check
- // causes race conditions where the closure captures stale state
- if (isZoomKeyPressed && isInContainer) {
- event.preventDefault();
- }
- };
-
- document.addEventListener("wheel", preventZoom, {
- passive: false,
- capture: true,
- });
-
- return () => {
- document.removeEventListener("wheel", preventZoom, { capture: true });
- };
- }, [containerRef]);
-
- return {
- zoomLevel,
- setZoomLevel: wrappedSetZoomLevel,
- handleWheel,
- saveScrollPosition,
- };
-}
diff --git a/apps/web/src/hooks/use-editor.ts b/apps/web/src/hooks/use-editor.ts
deleted file mode 100644
index ce48e036..00000000
--- a/apps/web/src/hooks/use-editor.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
-import { EditorCore } from "@/core";
-
-function isShallowEqual(a: unknown, b: unknown): boolean {
- if (Object.is(a, b)) return true;
- if (!Array.isArray(a) || !Array.isArray(b)) return false;
- if (a.length !== b.length) return false;
- return a.every((item, i) => Object.is(item, b[i]));
-}
-
-const subscribeNone = () => () => {};
-
-export function useEditor(): EditorCore;
-export function useEditor<T>(selector: (editor: EditorCore) => T): T;
-export function useEditor<T>(
- selector?: (editor: EditorCore) => T,
-): EditorCore | T {
- const editor = useMemo(() => EditorCore.getInstance(), []);
- const selectorRef = useRef(selector);
- selectorRef.current = selector;
-
- const snapshotCacheRef = useRef<unknown>(undefined);
-
- const subscribeAll = useCallback(
- (onChange: () => void) => {
- const unsubscribers = [
- editor.playback.subscribe(onChange),
- editor.timeline.subscribe(onChange),
- editor.scenes.subscribe(onChange),
- editor.project.subscribe(onChange),
- editor.media.subscribe(onChange),
- editor.renderer.subscribe(onChange),
- editor.selection.subscribe(onChange),
- editor.clipboard.subscribe(onChange),
- editor.diagnostics.subscribe(onChange),
- ];
- return () => {
- unsubscribers.forEach((unsubscribe) => {
- unsubscribe();
- });
- };
- },
- [editor],
- );
-
- const getSnapshot = useCallback(() => {
- const next = selectorRef.current ? selectorRef.current(editor) : editor;
- if (isShallowEqual(snapshotCacheRef.current, next)) {
- return snapshotCacheRef.current;
- }
- snapshotCacheRef.current = next;
- return next;
- }, [editor]);
-
- return useSyncExternalStore(
- selector ? subscribeAll : subscribeNone,
- getSnapshot,
- getSnapshot,
- ) as EditorCore | T;
-}
diff --git a/apps/web/src/hooks/use-element-preview.ts b/apps/web/src/hooks/use-element-preview.ts
deleted file mode 100644
index 4f28be83..00000000
--- a/apps/web/src/hooks/use-element-preview.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useEditor } from "@/hooks/use-editor";
-import { findTrackInSceneTracks, type TimelineElement } from "@/lib/timeline";
-
-/**
- * Subscribes to render tracks and returns the live (preview-aware) version of
- * an element alongside helpers for previewing and committing updates.
- *
- * Use this wherever property fields need to reflect in-progress preview state
- * (e.g. a slider being dragged) rather than the last committed value.
- */
-export function useElementPreview<T extends TimelineElement>({
- trackId,
- elementId,
- fallback,
-}: {
- trackId: string;
- elementId: string;
- fallback: T;
-}) {
- const editor = useEditor();
- useEditor((e) => e.timeline.getPreviewTracks());
-
- const previewTracks = editor.timeline.getPreviewTracks();
- const renderElement =
- (findTrackInSceneTracks({
- tracks: previewTracks ?? editor.scenes.getActiveScene().tracks,
- trackId,
- })?.elements.find((element) => element.id === elementId) as T | undefined) ??
- fallback;
-
- const previewUpdates = (updates: Partial<TimelineElement>) =>
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates }],
- });
-
- const commit = () => editor.timeline.commitPreview();
-
- return { renderElement, previewUpdates, commit };
-}
diff --git a/apps/web/src/hooks/use-file-upload.ts b/apps/web/src/hooks/use-file-upload.ts
deleted file mode 100644
index d174e002..00000000
--- a/apps/web/src/hooks/use-file-upload.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useState, useRef } from "react";
-import { hasDragData } from "@/lib/drag-data";
-
-interface UseFileUploadOptions {
- accept?: string;
- multiple?: boolean;
- onFilesSelected?: (files: File[]) => void;
-}
-
-function containsFiles(dataTransfer: DataTransfer): boolean {
- return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files");
-}
-
-export function useFileUpload({
- accept,
- multiple,
- onFilesSelected,
-}: UseFileUploadOptions = {}) {
- const [isDragOver, setIsDragOver] = useState(false);
- const dragCounterRef = useRef(0);
- const inputRef = useRef<HTMLInputElement>(null);
-
- function openFilePicker() {
- if (!inputRef.current) return;
-
- inputRef.current.accept = accept || "*";
- inputRef.current.multiple = multiple || false;
- inputRef.current.click();
- }
-
- function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
- const files = Array.from(event.target.files ?? []);
- if (files.length > 0 && onFilesSelected) {
- onFilesSelected(files);
- }
-
- if (event.target) {
- event.target.value = "";
- }
- }
-
- function handleDragEnter(e: React.DragEvent) {
- e.preventDefault();
-
- if (!containsFiles(e.dataTransfer)) return;
-
- dragCounterRef.current += 1;
- setIsDragOver(true);
- }
-
- function handleDragOver(e: React.DragEvent) {
- e.preventDefault();
-
- if (!containsFiles(e.dataTransfer)) return;
- }
-
- function handleDragLeave(e: React.DragEvent) {
- e.preventDefault();
-
- if (!containsFiles(e.dataTransfer)) return;
-
- dragCounterRef.current -= 1;
- if (dragCounterRef.current === 0) {
- setIsDragOver(false);
- }
- }
-
- function handleDrop(e: React.DragEvent) {
- e.preventDefault();
- setIsDragOver(false);
- dragCounterRef.current = 0;
-
- if (onFilesSelected && containsFiles(e.dataTransfer)) {
- const files = Array.from(e.dataTransfer.files);
- const shouldUseMultiple = multiple ?? false;
-
- if (shouldUseMultiple) {
- onFilesSelected(files);
- } else if (files.length > 0) {
- onFilesSelected([files[0]]);
- }
- }
- }
-
- return {
- isDragOver,
- openFilePicker,
- fileInputProps: {
- ref: inputRef,
- type: "file",
- style: { display: "none" },
- onChange: handleFileChange,
- },
- dragProps: {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- },
- };
-}
diff --git a/apps/web/src/hooks/use-font-atlas.ts b/apps/web/src/hooks/use-font-atlas.ts
deleted file mode 100644
index aa48f3b4..00000000
--- a/apps/web/src/hooks/use-font-atlas.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { useState, useMemo, useCallback, useEffect } from "react";
-import {
- getCachedFontAtlas,
- loadFontAtlas,
- clearFontAtlasCache,
-} from "@/lib/fonts/google-fonts";
-import type { FontAtlas } from "@/lib/fonts/types";
-import { SYSTEM_FONTS } from "@/lib/fonts/system-fonts";
-
-type Status = "idle" | "loading" | "error";
-
-export function useFontAtlas({ open }: { open: boolean }) {
- const [atlas, setAtlas] = useState<FontAtlas | null>(() =>
- getCachedFontAtlas(),
- );
- const [status, setStatus] = useState<Status>(() =>
- getCachedFontAtlas() ? "idle" : "loading",
- );
-
- useEffect(() => {
- if (!open || atlas) return;
-
- setStatus("loading");
- loadFontAtlas().then((data) => {
- if (data) {
- setAtlas(data);
- setStatus("idle");
- } else {
- setStatus("error");
- }
- });
- }, [open, atlas]);
-
- const retry = useCallback(() => {
- clearFontAtlasCache();
- setStatus("loading");
- loadFontAtlas().then((data) => {
- if (data) {
- setAtlas(data);
- setStatus("idle");
- } else {
- setStatus("error");
- }
- });
- }, []);
-
- const fontNames = useMemo(() => {
- if (!atlas) return [];
- return [...Object.keys(atlas.fonts), ...SYSTEM_FONTS].sort();
- }, [atlas]);
-
- return { atlas, status, fontNames, retry };
-}
diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts
deleted file mode 100644
index 0c524214..00000000
--- a/apps/web/src/hooks/use-keybindings.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { useEffect } from "react";
-import { invokeAction } from "@/lib/actions";
-import { useEditor } from "@/hooks/use-editor";
-import { useKeybindingsStore } from "@/stores/keybindings-store";
-import { isTypableDOMElement } from "@/utils/browser";
-
-/**
- * a composable that hooks to the caller component's
- * lifecycle and hooks to the keyboard events to fire
- * the appropriate actions based on keybindings
- */
-export function useKeybindingsListener() {
- const editor = useEditor();
- const {
- keybindings,
- getKeybindingString,
- overlayDepth,
- isLoadingProject,
- isRecording,
- } = useKeybindingsStore();
-
- useEffect(() => {
- const eventOptions: AddEventListenerOptions = { capture: true };
- const handleKeyDown = (ev: KeyboardEvent) => {
- const normalizedKey = (ev.key ?? "").toLowerCase();
-
- if (overlayDepth > 0 || isLoadingProject || isRecording) {
- return;
- }
-
- const binding = getKeybindingString(ev);
- const activeElement = document.activeElement;
- const isTextInput =
- activeElement instanceof HTMLElement &&
- isTypableDOMElement({ element: activeElement });
- const boundAction = binding ? keybindings[binding] : undefined;
-
- if (normalizedKey === "escape" && isTextInput) {
- activeElement.blur();
- return;
- }
-
- if (!binding) return;
- if (!boundAction) return;
-
- if (isTextInput) return;
- if (boundAction === "paste-copied") {
- if (!editor.clipboard.hasEntry()) return;
- ev.preventDefault();
- invokeAction("paste-copied", undefined, "keypress");
- return;
- }
-
- ev.preventDefault();
-
- switch (boundAction) {
- case "seek-forward":
- invokeAction("seek-forward", { seconds: 1 }, "keypress");
- break;
- case "seek-backward":
- invokeAction("seek-backward", { seconds: 1 }, "keypress");
- break;
- case "jump-forward":
- invokeAction("jump-forward", { seconds: 5 }, "keypress");
- break;
- case "jump-backward":
- invokeAction("jump-backward", { seconds: 5 }, "keypress");
- break;
- default:
- invokeAction(boundAction, undefined, "keypress");
- }
- };
-
- document.addEventListener("keydown", handleKeyDown, eventOptions);
-
- return () => {
- document.removeEventListener("keydown", handleKeyDown, eventOptions);
- };
- }, [
- keybindings,
- getKeybindingString,
- overlayDepth,
- isLoadingProject,
- isRecording,
- editor,
- ]);
-}
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
deleted file mode 100644
index 9a2f6cf5..00000000
--- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-import { useKeybindingsStore } from "@/stores/keybindings-store";
-import { ACTIONS, type TActionWithOptionalArgs } from "@/lib/actions";
-import {
- getPlatformAlternateKey,
- getPlatformSpecialKey,
-} from "@/utils/platform";
-
-export interface KeyboardShortcut {
- id: string;
- keys: string[];
- description: string;
- category: string;
- action: TActionWithOptionalArgs;
- icon?: React.ReactNode;
-}
-
-function formatKey({ key }: { key: string }): string {
- return key
- .replace("ctrl", getPlatformSpecialKey())
- .replace("alt", getPlatformAlternateKey())
- .replace("shift", "Shift")
- .replace("left", "←")
- .replace("right", "→")
- .replace("up", "↑")
- .replace("down", "↓")
- .replace("space", "Space")
- .replace("home", "Home")
- .replace("enter", "Enter")
- .replace("end", "End")
- .replace("delete", "Delete")
- .replace("backspace", "Backspace")
- .replace("-", "+");
-}
-
-export function useKeyboardShortcutsHelp() {
- const { keybindings } = useKeybindingsStore();
-
- const shortcuts = useMemo(() => {
- const result: KeyboardShortcut[] = [];
- const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {};
-
- for (const [key, action] of Object.entries(keybindings) as Array<
- [string, TActionWithOptionalArgs | undefined]
- >) {
- if (action) {
- if (!actionToKeys[action]) {
- actionToKeys[action] = [];
- }
- actionToKeys[action].push(formatKey({ key }));
- }
- }
-
- for (const [action, keys] of Object.entries(actionToKeys) as Array<
- [TActionWithOptionalArgs, string[]]
- >) {
- const actionDef = ACTIONS[action];
- if (!actionDef) {
- continue;
- }
- result.push({
- id: action,
- keys,
- description: actionDef.description,
- category: actionDef.category,
- action,
- });
- }
-
- return result.sort((a, b) => {
- if (a.category !== b.category) {
- return a.category.localeCompare(b.category);
- }
- return a.description.localeCompare(b.description);
- });
- }, [keybindings]);
-
- return {
- shortcuts,
- };
-}
diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts
deleted file mode 100644
index 917a0677..00000000
--- a/apps/web/src/hooks/use-mask-handles.ts
+++ /dev/null
@@ -1,633 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useEditor } from "@/hooks/use-editor";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import { masksRegistry } from "@/lib/masks";
-import {
- parseCustomMaskHandleId,
- parseCustomMaskSegmentHandleId,
-} from "@/lib/masks/custom-path";
-import { appendPointToCustomMask } from "@/lib/masks/definitions/custom";
-import {
- getVisibleElementsWithBounds,
- type ElementBounds,
-} from "@/lib/preview/element-bounds";
-import {
- SNAP_THRESHOLD_SCREEN_PIXELS,
- type SnapLine,
-} from "@/lib/preview/preview-snap";
-import type { SelectedMaskPointSelection } from "@/lib/selection/editor-selection";
-import type { Mask, MaskInteractionResult } from "@/lib/masks/types";
-import type { MaskableElement } from "@/lib/timeline";
-import { registerCanceller } from "@/lib/cancel-interaction";
-
-interface DragState {
- trackId: string;
- elementId: string;
- handleId: string;
- startCanvasX: number;
- startCanvasY: number;
- startParams: Mask["params"];
-}
-
-interface PendingSegmentInsertState {
- trackId: string;
- elementId: string;
- maskId: string;
- segmentIndex: number;
- startClientX: number;
- startClientY: number;
- startCanvasX: number;
- startCanvasY: number;
- startParams: Mask["params"];
- bounds: ElementBounds;
-}
-
-const SEGMENT_CLICK_DRAG_THRESHOLD_PX = 4;
-
-function isMaskSelectionForElement({
- trackId,
- elementId,
- maskId,
- selection,
-}: {
- trackId: string;
- elementId: string;
- maskId: string;
- selection: SelectedMaskPointSelection | null;
-}): boolean {
- if (!selection) {
- return false;
- }
- return (
- selection.trackId === trackId &&
- selection.elementId === elementId &&
- selection.maskId === maskId
- );
-}
-
-function replaceElementMask({
- masks,
- updatedMask,
-}: {
- masks: MaskableElement["masks"];
- updatedMask: Mask;
-}): Mask[] {
- return (masks ?? []).map((mask) =>
- mask.id === updatedMask.id ? updatedMask : mask,
- );
-}
-
-function withUpdatedMaskParams<TMask extends Mask>({
- mask,
- params,
-}: {
- mask: TMask;
- params: TMask["params"];
-}): TMask {
- return {
- ...mask,
- params,
- };
-}
-
-export function useMaskHandles({
- onSnapLinesChange,
-}: {
- onSnapLinesChange?: (lines: SnapLine[]) => void;
-}) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const viewport = usePreviewViewport();
- const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
- const dragStateRef = useRef<DragState | null>(null);
- const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>(
- null,
- );
- const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
- null,
- );
-
- const tracks = useEditor(
- (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
- );
- const currentTime = useEditor((e) => e.playback.getCurrentTime());
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const canvasSize = useEditor(
- (e) => e.project.getActive().settings.canvasSize,
- );
- const selectedElements = useEditor((e) => e.selection.getSelectedElements());
- const selectedMaskPointSelection = useEditor((e) =>
- e.selection.getSelectedMaskPointSelection(),
- );
-
- const elementsWithBounds = getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
- });
-
- const selectedWithMask =
- selectedElements.length === 1
- ? (() => {
- const sel = selectedElements[0];
- const entry = elementsWithBounds.find(
- (item) =>
- item.trackId === sel.trackId && item.elementId === sel.elementId,
- );
- if (!entry) return null;
- const element = entry.element as MaskableElement;
- const masks = element.masks ?? [];
- if (masks.length === 0) return null;
- const activeMaskId = masks.some((mask) =>
- isMaskSelectionForElement({
- trackId: entry.trackId,
- elementId: entry.elementId,
- maskId: mask.id,
- selection: selectedMaskPointSelection,
- }),
- )
- ? selectedMaskPointSelection?.maskId
- : masks[0].id;
- const mask =
- masks.find((candidate) => candidate.id === activeMaskId) ??
- masks[0];
- return { ...entry, element, mask };
- })()
- : null;
-
- const { handles: baseHandlePositions, overlays }: MaskInteractionResult =
- selectedWithMask
- ? (() => {
- const def = masksRegistry.get(selectedWithMask.mask.type);
- const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
- const displayScale = (scaleX + scaleY) / 2;
- return def.interaction.getInteraction({
- params: selectedWithMask.mask.params,
- bounds: selectedWithMask.bounds,
- displayScale,
- scaleX,
- scaleY,
- });
- })()
- : { handles: [], overlays: [] };
-
- const selectedPointIds = new Set(
- selectedWithMask &&
- isMaskSelectionForElement({
- trackId: selectedWithMask.trackId,
- elementId: selectedWithMask.elementId,
- maskId: selectedWithMask.mask.id,
- selection: selectedMaskPointSelection,
- })
- ? (selectedMaskPointSelection?.pointIds ?? [])
- : [],
- );
-
- const handlePositions = baseHandlePositions.map((handle) => {
- const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id });
- if (!parsedHandle || parsedHandle.part !== "anchor") {
- return handle;
- }
- return {
- ...handle,
- isSelected: selectedPointIds.has(parsedHandle.pointId),
- };
- });
-
- const customMaskPointIds =
- selectedWithMask?.mask.type === "custom"
- ? handlePositions
- .filter((h) => h.kind === "point")
- .map((h) => {
- const parsedHandle = parseCustomMaskHandleId({ handleId: h.id });
- return parsedHandle?.part === "anchor"
- ? parsedHandle.pointId
- : null;
- })
- .filter((id): id is string => id !== null)
- : [];
- const isCreatingCustomMask =
- selectedWithMask?.mask.type === "custom" &&
- (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0);
-
- useEffect(() => {
- if (!selectedMaskPointSelection) {
- return;
- }
- if (
- !selectedWithMask ||
- selectedWithMask.mask.type !== "custom" ||
- !isMaskSelectionForElement({
- trackId: selectedWithMask.trackId,
- elementId: selectedWithMask.elementId,
- maskId: selectedWithMask.mask.id,
- selection: selectedMaskPointSelection,
- })
- ) {
- editor.selection.clearMaskPointSelection();
- return;
- }
-
- const availablePointIds = new Set(customMaskPointIds);
- const nextSelectedPointIds = selectedMaskPointSelection.pointIds.filter(
- (pointId) => availablePointIds.has(pointId),
- );
- if (
- nextSelectedPointIds.length === selectedMaskPointSelection.pointIds.length
- ) {
- return;
- }
- if (nextSelectedPointIds.length === 0) {
- editor.selection.clearMaskPointSelection();
- return;
- }
-
- editor.selection.setSelectedMaskPoints({
- selection: {
- ...selectedMaskPointSelection,
- pointIds: nextSelectedPointIds,
- },
- });
- }, [
- customMaskPointIds,
- editor.selection,
- selectedMaskPointSelection,
- selectedWithMask,
- ]);
-
- const updateCustomMaskPointSelection = useCallback(
- ({
- pointId,
- toggleSelection,
- }: {
- pointId: string;
- toggleSelection: boolean;
- }) => {
- if (!selectedWithMask || selectedWithMask.mask.type !== "custom") {
- return;
- }
-
- const isSelectionForCurrentMask = isMaskSelectionForElement({
- trackId: selectedWithMask.trackId,
- elementId: selectedWithMask.elementId,
- maskId: selectedWithMask.mask.id,
- selection: selectedMaskPointSelection,
- });
- const currentPointIds = isSelectionForCurrentMask
- ? (selectedMaskPointSelection?.pointIds ?? [])
- : [];
- const nextPointIds = toggleSelection
- ? currentPointIds.includes(pointId)
- ? currentPointIds.filter(
- (currentPointId) => currentPointId !== pointId,
- )
- : [...currentPointIds, pointId]
- : [pointId];
-
- if (nextPointIds.length === 0) {
- editor.selection.clearMaskPointSelection();
- return;
- }
-
- editor.selection.setSelectedMaskPoints({
- selection: {
- trackId: selectedWithMask.trackId,
- elementId: selectedWithMask.elementId,
- maskId: selectedWithMask.mask.id,
- pointIds: nextPointIds,
- },
- });
- },
- [editor.selection, selectedMaskPointSelection, selectedWithMask],
- );
-
- const clearMaskHandleState = useCallback(() => {
- dragStateRef.current = null;
- pendingSegmentInsertRef.current = null;
- setActiveHandleId(null);
- onSnapLinesChange?.([]);
- }, [onSnapLinesChange]);
-
- const releaseCapturedPointer = useCallback(() => {
- const capture = captureRef.current;
- if (!capture) return;
-
- if (capture.element.hasPointerCapture(capture.pointerId)) {
- capture.element.releasePointerCapture(capture.pointerId);
- }
-
- captureRef.current = null;
- }, []);
-
- useEffect(() => {
- if (!activeHandleId) return;
-
- return registerCanceller({
- fn: () => {
- editor.timeline.discardPreview();
- clearMaskHandleState();
- releaseCapturedPointer();
- },
- });
- }, [
- activeHandleId,
- clearMaskHandleState,
- editor.timeline,
- releaseCapturedPointer,
- ]);
-
- const handlePointerDown = useCallback(
- ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
- if (!selectedWithMask) return;
- if (event.button !== 0) return;
- event.stopPropagation();
- const parsedHandle =
- selectedWithMask.mask.type === "custom"
- ? parseCustomMaskHandleId({ handleId })
- : null;
- const parsedSegmentHandle =
- selectedWithMask.mask.type === "custom"
- ? parseCustomMaskSegmentHandleId({ handleId })
- : null;
- if (isCreatingCustomMask) {
- const firstPointId = customMaskPointIds[0];
- if (
- firstPointId &&
- handleId === `point:${firstPointId}:anchor` &&
- customMaskPointIds.length >= 3 &&
- selectedWithMask.mask.type === "custom"
- ) {
- const updatedMask = withUpdatedMaskParams({
- mask: selectedWithMask.mask,
- params: {
- ...selectedWithMask.mask.params,
- closed: true,
- },
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId: selectedWithMask.trackId,
- elementId: selectedWithMask.elementId,
- patch: {
- masks: replaceElementMask({
- masks: selectedWithMask.element.masks,
- updatedMask,
- }),
- } as Partial<MaskableElement>,
- },
- ],
- });
- }
- return;
- }
-
- const pos = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!pos) return;
-
- if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") {
- setActiveHandleId(handleId);
- pendingSegmentInsertRef.current = {
- trackId: selectedWithMask.trackId,
- elementId: selectedWithMask.elementId,
- maskId: selectedWithMask.mask.id,
- segmentIndex: parsedSegmentHandle.segmentIndex,
- startClientX: event.clientX,
- startClientY: event.clientY,
- startCanvasX: pos.x,
- startCanvasY: pos.y,
- startParams: { ...selectedWithMask.mask.params },
- bounds: selectedWithMask.bounds,
- };
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- return;
- }
-
- if (parsedHandle?.part === "anchor") {
- updateCustomMaskPointSelection({
- pointId: parsedHandle.pointId,
- toggleSelection: event.shiftKey,
- });
- if (event.shiftKey) {
- return;
- }
- } else if (selectedWithMask.mask.type === "custom") {
- editor.selection.clearMaskPointSelection();
- }
-
- dragStateRef.current = {
- trackId: selectedWithMask.trackId,
- elementId: selectedWithMask.elementId,
- handleId,
- startCanvasX: pos.x,
- startCanvasY: pos.y,
- startParams: { ...selectedWithMask.mask.params },
- };
- setActiveHandleId(handleId);
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [
- customMaskPointIds,
- editor.selection,
- editor.timeline,
- isCreatingCustomMask,
- selectedWithMask,
- updateCustomMaskPointSelection,
- viewport,
- ],
- );
-
- const handleCanvasPointerDown = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (!selectedWithMask || !isCreatingCustomMask) {
- return;
- }
- if (event.button !== 0) {
- return;
- }
- if (selectedWithMask.mask.type !== "custom") {
- return;
- }
-
- event.stopPropagation();
- const pos = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!pos) {
- return;
- }
-
- const nextParams = appendPointToCustomMask({
- params: selectedWithMask.mask.params,
- canvasPoint: pos,
- bounds: selectedWithMask.bounds,
- });
- const updatedMask = withUpdatedMaskParams({
- mask: selectedWithMask.mask,
- params: nextParams,
- });
-
- editor.timeline.updateElements({
- updates: [
- {
- trackId: selectedWithMask.trackId,
- elementId: selectedWithMask.elementId,
- patch: {
- masks: replaceElementMask({
- masks: selectedWithMask.element.masks,
- updatedMask,
- }),
- } as Partial<MaskableElement>,
- },
- ],
- });
- },
- [editor.timeline, isCreatingCustomMask, selectedWithMask, viewport],
- );
-
- const handlePointerMove = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- const pendingSegmentInsert = pendingSegmentInsertRef.current;
- if (pendingSegmentInsert && !dragStateRef.current) {
- const distance = Math.hypot(
- event.clientX - pendingSegmentInsert.startClientX,
- event.clientY - pendingSegmentInsert.startClientY,
- );
- if (distance >= SEGMENT_CLICK_DRAG_THRESHOLD_PX) {
- dragStateRef.current = {
- trackId: pendingSegmentInsert.trackId,
- elementId: pendingSegmentInsert.elementId,
- handleId: "position",
- startCanvasX: pendingSegmentInsert.startCanvasX,
- startCanvasY: pendingSegmentInsert.startCanvasY,
- startParams: pendingSegmentInsert.startParams,
- };
- pendingSegmentInsertRef.current = null;
- setActiveHandleId("position");
- }
- }
-
- const drag = dragStateRef.current;
- if (!drag || !selectedWithMask) return;
-
- const pos = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!pos) return;
-
- const deltaX = pos.x - drag.startCanvasX;
- const deltaY = pos.y - drag.startCanvasY;
- const def = masksRegistry.get(selectedWithMask.mask.type);
-
- const rawParams = def.computeParamUpdate({
- handleId: drag.handleId,
- startParams: drag.startParams,
- deltaX,
- deltaY,
- startCanvasX: drag.startCanvasX,
- startCanvasY: drag.startCanvasY,
- bounds: selectedWithMask.bounds,
- canvasSize,
- });
- const proposedParams = { ...drag.startParams, ...rawParams };
-
- const snapThreshold = viewport.screenPixelsToLogicalThreshold({
- screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
- });
- const { params: nextParams, activeLines } = isShiftHeldRef.current
- ? { params: proposedParams, activeLines: [] as SnapLine[] }
- : (def.interaction.snap?.({
- handleId: drag.handleId,
- startParams: drag.startParams,
- proposedParams,
- bounds: selectedWithMask.bounds,
- canvasSize,
- snapThreshold,
- }) ?? { params: proposedParams, activeLines: [] as SnapLine[] });
-
- onSnapLinesChange?.(activeLines);
-
- const updatedMask = withUpdatedMaskParams({
- mask: selectedWithMask.mask,
- params: nextParams as typeof selectedWithMask.mask.params,
- });
- editor.timeline.previewElements({
- updates: [
- {
- trackId: drag.trackId,
- elementId: drag.elementId,
- updates: {
- masks: replaceElementMask({
- masks: selectedWithMask.element.masks,
- updatedMask,
- }),
- } as Partial<MaskableElement>,
- },
- ],
- });
- },
- [
- selectedWithMask,
- canvasSize,
- editor,
- isShiftHeldRef,
- onSnapLinesChange,
- viewport,
- ],
- );
-
- const handlePointerUp = useCallback(() => {
- const pendingSegmentInsert = pendingSegmentInsertRef.current;
- if (pendingSegmentInsert && !dragStateRef.current) {
- editor.timeline.insertCustomMaskPoint({
- trackId: pendingSegmentInsert.trackId,
- elementId: pendingSegmentInsert.elementId,
- maskId: pendingSegmentInsert.maskId,
- segmentIndex: pendingSegmentInsert.segmentIndex,
- canvasPoint: {
- x: pendingSegmentInsert.startCanvasX,
- y: pendingSegmentInsert.startCanvasY,
- },
- bounds: pendingSegmentInsert.bounds,
- });
- clearMaskHandleState();
- releaseCapturedPointer();
- return;
- }
-
- if (dragStateRef.current) {
- editor.timeline.commitPreview();
- clearMaskHandleState();
- }
- releaseCapturedPointer();
- }, [clearMaskHandleState, editor, releaseCapturedPointer]);
-
- return {
- selectedWithMask,
- handlePositions,
- overlays,
- isCreatingCustomMask,
- handleCanvasPointerDown,
- activeHandleId,
- handlePointerDown,
- handlePointerMove,
- handlePointerUp,
- };
-}
diff --git a/apps/web/src/hooks/use-menu-preview.ts b/apps/web/src/hooks/use-menu-preview.ts
deleted file mode 100644
index a3c14489..00000000
--- a/apps/web/src/hooks/use-menu-preview.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useRef } from "react";
-import { useEditor } from "@/hooks/use-editor";
-
-export function useMenuPreview() {
- const editor = useEditor();
- const didCommitRef = useRef(false);
-
- const discard = () => {
- if (!didCommitRef.current && editor.timeline.isPreviewActive()) {
- editor.timeline.discardPreview();
- }
- };
-
- const markCommitted = () => {
- didCommitRef.current = true;
- };
-
- const onOpenChange = (isOpen: boolean) => {
- if (!isOpen) {
- discard();
- didCommitRef.current = false;
- }
- };
-
- return { onPointerLeave: discard, onOpenChange, markCommitted };
-}
diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts
deleted file mode 100644
index 95020a04..00000000
--- a/apps/web/src/hooks/use-paste-media.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { useEffect } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { processMediaAssets } from "@/lib/media/processing";
-import { showMediaUploadToast } from "@/lib/media/upload-toast";
-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 { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import { isTypableDOMElement } from "@/utils/browser";
-import type { MediaType } from "@/lib/media/types";
-
-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) {
- event.preventDefault();
- editor.clipboard.paste();
- return;
- }
-
- event.preventDefault();
-
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- try {
- await showMediaUploadToast({
- filesCount: files.length,
- promise: async () => {
- 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 != null
- ? Math.round(asset.duration * TICKS_PER_SECOND)
- : DEFAULT_NEW_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 });
- }
-
- return {
- uploadedCount: processedAssets.length,
- assetNames: processedAssets.map((asset) => asset.name),
- };
- },
- });
- } catch (error) {
- console.error("Failed to paste media:", error);
- }
- };
-
- window.addEventListener("paste", handlePaste);
- return () => window.removeEventListener("paste", handlePaste);
- }, [editor]);
-}
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
deleted file mode 100644
index cfb3d527..00000000
--- a/apps/web/src/hooks/use-preview-interaction.ts
+++ /dev/null
@@ -1,456 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import type { Transform } from "@/lib/rendering";
-import type { ElementRef, TextElement } from "@/lib/timeline";
-import {
- getVisibleElementsWithBounds,
- type ElementWithBounds,
-} from "@/lib/preview/element-bounds";
-import {
- getHitElements,
- hitTest,
- resolvePreferredHit,
-} from "@/lib/preview/hit-test";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import {
- SNAP_THRESHOLD_SCREEN_PIXELS,
- snapPosition,
- type SnapLine,
-} from "@/lib/preview/preview-snap";
-import { registerCanceller } from "@/lib/cancel-interaction";
-
-export type OnSnapLinesChange = (lines: SnapLine[]) => void;
-
-const MIN_DRAG_DISTANCE = 0.5;
-
-interface CapturedPointerState {
- pointerId: number;
- captureTarget: HTMLElement;
-}
-
-interface PendingGestureState extends CapturedPointerState {
- startX: number;
- startY: number;
- topmostHit: ElementWithBounds | null;
- selectedHit: ElementWithBounds | null;
- selectedElements: ElementRef[];
-}
-
-interface DragState extends CapturedPointerState {
- startX: number;
- startY: number;
- bounds: {
- width: number;
- height: number;
- rotation: number;
- };
- elements: Array<{
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- }>;
-}
-
-function isSameElementRef({
- left,
- right,
-}: {
- left: ElementRef;
- right: ElementRef;
-}): boolean {
- return left.trackId === right.trackId && left.elementId === right.elementId;
-}
-
-function buildDragSelection({
- selectedElements,
- dragTarget,
-}: {
- selectedElements: ElementRef[];
- dragTarget: ElementWithBounds;
-}): ElementRef[] {
- const dragTargetRef = {
- trackId: dragTarget.trackId,
- elementId: dragTarget.elementId,
- };
-
- if (
- !selectedElements.some((selectedElement) =>
- isSameElementRef({ left: selectedElement, right: dragTargetRef }),
- )
- ) {
- return [dragTargetRef];
- }
-
- return [
- dragTargetRef,
- ...selectedElements.filter(
- (selectedElement) =>
- !isSameElementRef({ left: selectedElement, right: dragTargetRef }),
- ),
- ];
-}
-
-export function usePreviewInteraction({
- onSnapLinesChange,
- isMaskMode = false,
-}: {
- onSnapLinesChange?: OnSnapLinesChange;
- isMaskMode?: boolean;
-}) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const viewport = usePreviewViewport();
- const [isDragging, setIsDragging] = useState(false);
- const [editingText, setEditingText] = useState<{
- trackId: string;
- elementId: string;
- element: TextElement;
- originalOpacity: number;
- } | null>(null);
- const dragStateRef = useRef<DragState | null>(null);
- const pendingGestureRef = useRef<PendingGestureState | null>(null);
- const wasPlayingRef = useRef(editor.playback.getIsPlaying());
- const editingTextRef = useRef(editingText);
- editingTextRef.current = editingText;
-
- const releaseCapturedPointer = useCallback(
- (pointerState: CapturedPointerState | null) => {
- if (!pointerState) return;
-
- if (
- !pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)
- ) {
- return;
- }
-
- pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
- },
- [],
- );
-
- const commitTextEdit = useCallback(() => {
- const current = editingTextRef.current;
- if (!current) return;
- editingTextRef.current = null;
- editor.timeline.commitPreview();
- setEditingText(null);
- }, [editor.timeline]);
-
- 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]);
-
- useEffect(() => {
- if (!isDragging) return;
-
- return registerCanceller({
- fn: () => {
- const dragState = dragStateRef.current;
- if (!dragState) return;
-
- editor.timeline.discardPreview();
- dragStateRef.current = null;
- pendingGestureRef.current = null;
- setIsDragging(false);
- onSnapLinesChange?.([]);
- releaseCapturedPointer(dragState);
- },
- });
- }, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]);
-
- const handleDoubleClick = useCallback(
- ({ clientX, clientY }: React.MouseEvent) => {
- if (editingText || isMaskMode) return;
-
- const tracks = editor.scenes.getActiveScene().tracks;
- const currentTime = editor.playback.getCurrentTime();
- const mediaAssets = editor.media.getAssets();
- const canvasSize = editor.project.getActive().settings.canvasSize;
-
- const startPos = viewport.screenToCanvas({
- clientX,
- clientY,
- });
- if (!startPos) return;
-
- 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;
- setEditingText({
- trackId: hit.trackId,
- elementId: hit.elementId,
- element: textElement,
- originalOpacity: textElement.opacity,
- });
- },
- [editor, editingText, isMaskMode, viewport],
- );
-
- const handlePointerDown = useCallback(
- ({
- clientX,
- clientY,
- currentTarget,
- pointerId,
- button,
- }: React.PointerEvent) => {
- if (editingText) return;
- if (isMaskMode) return;
- if (button !== 0) return;
-
- const tracks = editor.scenes.getActiveScene().tracks;
- const currentTime = editor.playback.getCurrentTime();
- const mediaAssets = editor.media.getAssets();
- const canvasSize = editor.project.getActive().settings.canvasSize;
-
- const startPos = viewport.screenToCanvas({
- clientX,
- clientY,
- });
- if (!startPos) return;
-
- const elementsWithBounds = getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
- });
-
- const hits = getHitElements({
- canvasX: startPos.x,
- canvasY: startPos.y,
- elementsWithBounds,
- });
- const selectedElements = editor.selection.getSelectedElements();
- const topmostHit = hits[0] ?? null;
-
- pendingGestureRef.current = {
- startX: startPos.x,
- startY: startPos.y,
- pointerId,
- captureTarget: currentTarget as HTMLElement,
- topmostHit,
- selectedHit: resolvePreferredHit({
- hits,
- preferredElements: selectedElements,
- }),
- selectedElements,
- };
- currentTarget.setPointerCapture(pointerId);
- },
- [editor, editingText, isMaskMode, viewport],
- );
-
- const handlePointerMove = useCallback(
- ({ clientX, clientY }: React.PointerEvent) => {
- const canvasSize = editor.project.getActive().settings.canvasSize;
-
- const currentPos = viewport.screenToCanvas({
- clientX,
- clientY,
- });
- if (!currentPos) return;
-
- let dragState = dragStateRef.current;
-
- if (!dragState) {
- const pendingGesture = pendingGestureRef.current;
- if (!pendingGesture) return;
-
- const deltaX = currentPos.x - pendingGesture.startX;
- const deltaY = currentPos.y - pendingGesture.startY;
- const hasMovement =
- Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
- Math.abs(deltaY) > MIN_DRAG_DISTANCE;
-
- if (!hasMovement) {
- onSnapLinesChange?.([]);
- return;
- }
-
- const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
- if (!dragTarget) {
- pendingGestureRef.current = null;
- onSnapLinesChange?.([]);
- releaseCapturedPointer(pendingGesture);
- return;
- }
-
- const dragSelection = buildDragSelection({
- selectedElements: pendingGesture.selectedElements,
- dragTarget,
- });
- const elementsWithTracks = editor.timeline.getElementsWithTracks({
- elements: dragSelection,
- });
- const draggableElements = elementsWithTracks.filter(({ element }) =>
- isVisualElement(element),
- );
-
- if (draggableElements.length === 0) {
- pendingGestureRef.current = null;
- onSnapLinesChange?.([]);
- releaseCapturedPointer(pendingGesture);
- return;
- }
-
- if (pendingGesture.selectedHit === null) {
- editor.selection.setSelectedElements({
- elements: [
- {
- trackId: dragTarget.trackId,
- elementId: dragTarget.elementId,
- },
- ],
- });
- }
-
- dragState = {
- startX: pendingGesture.startX,
- startY: pendingGesture.startY,
- pointerId: pendingGesture.pointerId,
- captureTarget: pendingGesture.captureTarget,
- bounds: {
- width: dragTarget.bounds.width,
- height: dragTarget.bounds.height,
- rotation: dragTarget.bounds.rotation,
- },
- elements: draggableElements.map(({ track, element }) => ({
- trackId: track.id,
- elementId: element.id,
- initialTransform: (element as { transform: Transform }).transform,
- })),
- };
- dragStateRef.current = dragState;
- pendingGestureRef.current = null;
- setIsDragging(true);
- }
-
- const deltaX = currentPos.x - dragState.startX;
- const deltaY = currentPos.y - dragState.startY;
- const firstElement = dragState.elements[0];
- const proposedPosition = {
- x: firstElement.initialTransform.position.x + deltaX,
- y: firstElement.initialTransform.position.y + deltaY,
- };
-
- const shouldSnap = !isShiftHeldRef.current;
- const snapThreshold = viewport.screenPixelsToLogicalThreshold({
- screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
- });
- const { snappedPosition, activeLines } = shouldSnap
- ? snapPosition({
- proposedPosition,
- canvasSize,
- elementSize: dragState.bounds,
- rotation: dragState.bounds.rotation,
- snapThreshold,
- })
- : {
- snappedPosition: proposedPosition,
- activeLines: [] as SnapLine[],
- };
-
- onSnapLinesChange?.(activeLines);
-
- const deltaSnappedX =
- snappedPosition.x - firstElement.initialTransform.position.x;
- const deltaSnappedY =
- snappedPosition.y - firstElement.initialTransform.position.y;
-
- const updates = dragState.elements.map(
- ({ trackId, elementId, initialTransform }) => ({
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- position: {
- x: initialTransform.position.x + deltaSnappedX,
- y: initialTransform.position.y + deltaSnappedY,
- },
- },
- },
- }),
- );
-
- editor.timeline.previewElements({ updates });
- },
- [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
- );
-
- const handlePointerUp = useCallback(
- ({ type }: React.PointerEvent) => {
- const dragState = dragStateRef.current;
- if (dragState) {
- if (type === "pointercancel") {
- editor.timeline.discardPreview();
- } else {
- editor.timeline.commitPreview();
- }
-
- dragStateRef.current = null;
- pendingGestureRef.current = null;
- setIsDragging(false);
- onSnapLinesChange?.([]);
- releaseCapturedPointer(dragState);
- return;
- }
-
- const pendingGesture = pendingGestureRef.current;
- if (!pendingGesture) return;
-
- if (type !== "pointercancel") {
- const clickTarget = pendingGesture.topmostHit;
- if (!clickTarget) {
- editor.selection.clearSelection();
- } else {
- editor.selection.setSelectedElements({
- elements: [
- {
- trackId: clickTarget.trackId,
- elementId: clickTarget.elementId,
- },
- ],
- });
- }
- }
-
- pendingGestureRef.current = null;
- onSnapLinesChange?.([]);
- releaseCapturedPointer(pendingGesture);
- },
- [editor, onSnapLinesChange, releaseCapturedPointer],
- );
-
- return {
- onPointerDown: handlePointerDown,
- onPointerMove: handlePointerMove,
- onPointerUp: handlePointerUp,
- onDoubleClick: handleDoubleClick,
- editingText,
- commitTextEdit,
- };
-}
diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts
deleted file mode 100644
index 263a01e2..00000000
--- a/apps/web/src/hooks/use-sound-search.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-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,
- };
-}
diff --git a/apps/web/src/hooks/use-storage-persistence.ts b/apps/web/src/hooks/use-storage-persistence.ts
deleted file mode 100644
index a026b510..00000000
--- a/apps/web/src/hooks/use-storage-persistence.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-
-const DISMISSED_KEY = "opencut-storage-persist-dismissed";
-
-function isFirefox(): boolean {
- return navigator.userAgent.toLowerCase().includes("firefox");
-}
-
-export function useStoragePersistence() {
- const [showDialog, setShowDialog] = useState(false);
-
- useEffect(() => {
- if (!navigator.storage?.persist) return;
-
- const run = async () => {
- const alreadyPersisted = await navigator.storage.persisted();
- if (alreadyPersisted) return;
-
- const dismissed = localStorage.getItem(DISMISSED_KEY) === "true";
- if (dismissed) return;
-
- if (isFirefox()) {
- setShowDialog(true);
- } else {
- await navigator.storage.persist();
- }
- };
-
- run();
- }, []);
-
- const onConfirm = async () => {
- setShowDialog(false);
- await navigator.storage.persist();
- };
-
- const onDismiss = () => {
- setShowDialog(false);
- localStorage.setItem(DISMISSED_KEY, "true");
- };
-
- return { showDialog, onConfirm, onDismiss };
-}
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
deleted file mode 100644
index 7495a387..00000000
--- a/apps/web/src/hooks/use-transform-handles.ts
+++ /dev/null
@@ -1,631 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
-import { useEditor } from "@/hooks/use-editor";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import {
- getVisibleElementsWithBounds,
- type ElementWithBounds,
-} from "@/lib/preview/element-bounds";
-import {
- MIN_SCALE,
- SNAP_THRESHOLD_SCREEN_PIXELS,
- snapRotation,
- snapScale,
- snapScaleAxes,
- type ScaleEdgePreference,
- type SnapLine,
-} from "@/lib/preview/preview-snap";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import {
- getElementLocalTime,
- hasKeyframesForPath,
- resolveTransformAtTime,
- setChannel,
-} from "@/lib/animation";
-import type { Transform } from "@/lib/rendering";
-import type { ElementAnimations } from "@/lib/animation/types";
-import { registerCanceller } from "@/lib/cancel-interaction";
-
-type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
-type Edge = "right" | "left" | "bottom";
-type HandleType = Corner | Edge | "rotation";
-
-function getPreferredEdge({
- edge,
-}: {
- edge: Edge;
-}): ScaleEdgePreference {
- return edge === "right"
- ? { right: true }
- : edge === "left"
- ? { left: true }
- : { bottom: true };
-}
-
-interface ScaleState {
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- initialDistance: number;
- initialBoundsCx: number;
- initialBoundsCy: number;
- baseWidth: number;
- baseHeight: number;
- shouldClearScaleAnimation: boolean;
- animationsWithoutScale: ElementAnimations | undefined;
-}
-
-interface RotationState {
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- initialAngle: number;
- initialBoundsCx: number;
- initialBoundsCy: number;
-}
-
-interface EdgeScaleState {
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- initialBoundsCx: number;
- initialBoundsCy: number;
- baseWidth: number;
- baseHeight: number;
- edge: Edge;
- rotationRad: number;
- shouldClearScaleAnimation: boolean;
- animationsWithoutScale: ElementAnimations | undefined;
-}
-
-function clampScaleNonZero(scale: number): number {
- if (Math.abs(scale) < MIN_SCALE) {
- return scale < 0 ? -MIN_SCALE : MIN_SCALE;
- }
- return scale;
-}
-
-function getCornerDistance({
- bounds,
- corner,
-}: {
- bounds: {
- cx: number;
- cy: number;
- width: number;
- height: number;
- rotation: number;
- };
- corner: Corner;
-}): number {
- const halfWidth = bounds.width / 2;
- const halfHeight = 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" ? -halfWidth : halfWidth;
- const localY =
- corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
-
- const rotatedX = localX * cos - localY * sin;
- const rotatedY = localX * sin + localY * cos;
- return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
-}
-
-export function useTransformHandles({
- onSnapLinesChange,
-}: {
- onSnapLinesChange?: OnSnapLinesChange;
-}) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const viewport = usePreviewViewport();
- const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
- const scaleStateRef = useRef<ScaleState | null>(null);
- const rotationStateRef = useRef<RotationState | null>(null);
- const edgeScaleStateRef = useRef<EdgeScaleState | null>(null);
- const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
- null,
- );
-
- const selectedElements = useEditor((e) => e.selection.getSelectedElements());
- const tracks = useEditor(
- (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
- );
- const currentTime = useEditor((e) => e.playback.getCurrentTime());
- const currentTimeRef = useRef(currentTime);
- currentTimeRef.current = currentTime;
- const mediaAssets = useEditor((e) => e.media.getAssets());
- const canvasSize = useEditor(
- (e) => e.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 clearActiveHandleState = useCallback(() => {
- scaleStateRef.current = null;
- rotationStateRef.current = null;
- edgeScaleStateRef.current = null;
- setActiveHandle(null);
- onSnapLinesChange?.([]);
- }, [onSnapLinesChange]);
-
- const releaseCapturedPointer = useCallback(() => {
- const capture = captureRef.current;
- if (!capture) return;
-
- if (capture.element.hasPointerCapture(capture.pointerId)) {
- capture.element.releasePointerCapture(capture.pointerId);
- }
-
- captureRef.current = null;
- }, []);
-
- useEffect(() => {
- if (!activeHandle) return;
-
- return registerCanceller({
- fn: () => {
- editor.timeline.discardPreview();
- clearActiveHandleState();
- releaseCapturedPointer();
- },
- });
- }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]);
-
- 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 localTime = getElementLocalTime({
- timelineTime: currentTimeRef.current,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const initialDistance = getCornerDistance({ bounds, corner });
- const baseWidth = bounds.width / resolvedTransform.scaleX;
- const baseHeight = bounds.height / resolvedTransform.scaleY;
- const shouldClearScaleAnimation =
- hasKeyframesForPath({
- animations: element.animations,
- propertyPath: "transform.scaleX",
- }) ||
- hasKeyframesForPath({
- animations: element.animations,
- propertyPath: "transform.scaleY",
- });
- const animationsWithoutScale = shouldClearScaleAnimation
- ? setChannel({
- animations: setChannel({
- animations: element.animations,
- propertyPath: "transform.scaleX",
- channel: undefined,
- }),
- propertyPath: "transform.scaleY",
- channel: undefined,
- })
- : element.animations;
-
- scaleStateRef.current = {
- trackId,
- elementId,
- initialTransform: resolvedTransform,
- initialDistance,
- initialBoundsCx: bounds.cx,
- initialBoundsCy: bounds.cy,
- baseWidth,
- baseHeight,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- };
- setActiveHandle(corner);
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [selectedWithBounds],
- );
-
- const handleRotationPointerDown = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (!selectedWithBounds) return;
- event.stopPropagation();
-
- const { bounds, trackId, elementId, element } = selectedWithBounds;
- if (!isVisualElement(element)) return;
-
- const localTime = getElementLocalTime({
- timelineTime: currentTimeRef.current,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const position = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!position) return;
- const deltaX = position.x - bounds.cx;
- const deltaY = position.y - bounds.cy;
- const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
-
- rotationStateRef.current = {
- trackId,
- elementId,
- initialTransform: resolvedTransform,
- initialAngle,
- initialBoundsCx: bounds.cx,
- initialBoundsCy: bounds.cy,
- };
- setActiveHandle("rotation");
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [selectedWithBounds, viewport],
- );
-
- const handleEdgePointerDown = useCallback(
- ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
- if (!selectedWithBounds) return;
- event.stopPropagation();
-
- const { bounds, trackId, elementId, element } = selectedWithBounds;
- if (!isVisualElement(element)) return;
-
- const localTime = getElementLocalTime({
- timelineTime: currentTimeRef.current,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const baseWidth = bounds.width / resolvedTransform.scaleX;
- const baseHeight = bounds.height / resolvedTransform.scaleY;
- const rotationRad = (bounds.rotation * Math.PI) / 180;
-
- const propertyPath =
- edge === "right" || edge === "left"
- ? "transform.scaleX"
- : "transform.scaleY";
- const shouldClearScaleAnimation =
- hasKeyframesForPath({
- animations: element.animations,
- propertyPath,
- });
- const animationsWithoutScale = shouldClearScaleAnimation
- ? setChannel({
- animations: element.animations,
- propertyPath,
- channel: undefined,
- })
- : element.animations;
-
- edgeScaleStateRef.current = {
- trackId,
- elementId,
- initialTransform: resolvedTransform,
- initialBoundsCx: bounds.cx,
- initialBoundsCy: bounds.cy,
- baseWidth,
- baseHeight,
- edge,
- rotationRad,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- };
- setActiveHandle(edge);
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [selectedWithBounds],
- );
-
- const handlePointerMove = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (
- !scaleStateRef.current &&
- !rotationStateRef.current &&
- !edgeScaleStateRef.current
- )
- return;
-
- const position = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!position) return;
-
- if (
- scaleStateRef.current &&
- activeHandle &&
- activeHandle !== "rotation"
- ) {
- const {
- trackId,
- elementId,
- initialTransform,
- initialDistance,
- initialBoundsCx,
- initialBoundsCy,
- baseWidth,
- baseHeight,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- } = scaleStateRef.current;
-
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentDistance =
- Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
- const scaleFactor = currentDistance / initialDistance;
-
- // Use actual element dimensions (base * current scale) so snap
- // computes the correct edges when scaleX ≠ scaleY
- const effectiveWidth = baseWidth * initialTransform.scaleX;
- const effectiveHeight = baseHeight * initialTransform.scaleY;
-
- const snapThreshold = viewport.screenPixelsToLogicalThreshold({
- screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
- });
- const { snappedScale: snappedFactor, activeLines } =
- isShiftHeldRef.current
- ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
- : snapScale({
- proposedScale: scaleFactor,
- position: initialTransform.position,
- baseWidth: effectiveWidth,
- baseHeight: effectiveHeight,
- rotation: initialTransform.rotate,
- canvasSize,
- snapThreshold,
- });
-
- onSnapLinesChange?.(activeLines);
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- scaleX: clampScaleNonZero(
- initialTransform.scaleX * snappedFactor,
- ),
- scaleY: clampScaleNonZero(
- initialTransform.scaleY * snappedFactor,
- ),
- },
- ...(shouldClearScaleAnimation && {
- animations: animationsWithoutScale,
- }),
- },
- },
- ],
- });
- return;
- }
-
- if (
- edgeScaleStateRef.current &&
- (activeHandle === "right" ||
- activeHandle === "left" ||
- activeHandle === "bottom")
- ) {
- const {
- trackId,
- elementId,
- initialTransform,
- initialBoundsCx,
- initialBoundsCy,
- baseWidth,
- baseHeight,
- edge,
- rotationRad,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- } = edgeScaleStateRef.current;
-
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const xProjection =
- deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
- const yProjection =
- -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
- const projection =
- edge === "right"
- ? xProjection
- : edge === "left"
- ? -xProjection
- : yProjection;
-
- const baseAxisHalf =
- edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
- const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
-
- const proposedScaleX =
- edge === "right" || edge === "left"
- ? proposedScale
- : initialTransform.scaleX;
- const proposedScaleY =
- edge === "bottom" ? proposedScale : initialTransform.scaleY;
-
- const snapThreshold = viewport.screenPixelsToLogicalThreshold({
- screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
- });
- const { x: xSnap, y: ySnap } = isShiftHeldRef.current
- ? {
- x: {
- snappedScale: proposedScaleX,
- snapDistance: Infinity,
- activeLines: [] as SnapLine[],
- },
- y: {
- snappedScale: proposedScaleY,
- snapDistance: Infinity,
- activeLines: [] as SnapLine[],
- },
- }
- : snapScaleAxes({
- proposedScaleX,
- proposedScaleY,
- position: initialTransform.position,
- baseWidth,
- baseHeight,
- rotation: initialTransform.rotate,
- canvasSize,
- snapThreshold,
- preferredEdges: getPreferredEdge({ edge }),
- });
-
- const relevantSnap =
- edge === "right" || edge === "left" ? xSnap : ySnap;
- onSnapLinesChange?.(relevantSnap.activeLines);
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- scaleX:
- edge === "right" || edge === "left"
- ? xSnap.snappedScale
- : initialTransform.scaleX,
- scaleY:
- edge === "bottom"
- ? ySnap.snappedScale
- : initialTransform.scaleY,
- },
- ...(shouldClearScaleAnimation && {
- animations: animationsWithoutScale,
- }),
- },
- },
- ],
- });
- return;
- }
-
- if (rotationStateRef.current && activeHandle === "rotation") {
- const {
- trackId,
- elementId,
- initialTransform,
- initialAngle,
- initialBoundsCx,
- initialBoundsCy,
- } = rotationStateRef.current;
-
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
- let deltaAngle = currentAngle - initialAngle;
- if (deltaAngle > 180) deltaAngle -= 360;
- if (deltaAngle < -180) deltaAngle += 360;
- const newRotate = initialTransform.rotate + deltaAngle;
- const { snappedRotation } = isShiftHeldRef.current
- ? { snappedRotation: newRotate }
- : snapRotation({ proposedRotation: newRotate });
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: { ...initialTransform, rotate: snappedRotation },
- },
- },
- ],
- });
- }
- },
- [
- activeHandle,
- canvasSize,
- editor,
- isShiftHeldRef,
- onSnapLinesChange,
- viewport,
- ],
- );
-
- const handlePointerUp = useCallback(() => {
- if (
- scaleStateRef.current ||
- rotationStateRef.current ||
- edgeScaleStateRef.current
- ) {
- editor.timeline.commitPreview();
- clearActiveHandleState();
- }
- releaseCapturedPointer();
- },
- [clearActiveHandleState, editor, releaseCapturedPointer],
- );
-
- return {
- selectedWithBounds,
- hasVisualSelection,
- activeHandle,
- handleCornerPointerDown,
- handleEdgePointerDown,
- handleRotationPointerDown,
- handlePointerMove,
- handlePointerUp,
- };
-}