summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/use-preview-interaction.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks/use-preview-interaction.ts')
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts345
1 files changed, 247 insertions, 98 deletions
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
index d29f2a4b..90fa0450 100644
--- a/apps/web/src/hooks/use-preview-interaction.ts
+++ b/apps/web/src/hooks/use-preview-interaction.ts
@@ -1,28 +1,50 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
-import type { TextElement, Transform } from "@/types/timeline";
-import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
-import { hitTest } from "@/lib/preview/hit-test";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import type { Transform } from "@/lib/rendering";
+import type { ElementRef, TextElement } from "@/lib/timeline";
import {
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
+ 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 DragState {
+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;
@@ -31,15 +53,56 @@ interface DragState {
}>;
}
+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({
- canvasRef,
+ onSnapLinesChange,
+ isMaskMode = false,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ onSnapLinesChange?: OnSnapLinesChange;
+ isMaskMode?: boolean;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
const [isDragging, setIsDragging] = useState(false);
- const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
const [editingText, setEditingText] = useState<{
trackId: string;
elementId: string;
@@ -47,13 +110,30 @@ export function usePreviewInteraction({
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.previewElements({
updates: [
{
@@ -67,11 +147,6 @@ export function usePreviewInteraction({
setEditingText(null);
}, [editor.timeline]);
- const cancelTextEdit = useCallback(() => {
- editor.timeline.discardPreview();
- setEditingText(null);
- }, [editor.timeline]);
-
useEffect(() => {
const unsubscribe = editor.playback.subscribe(() => {
const isPlaying = editor.playback.getIsPlaying();
@@ -83,20 +158,38 @@ export function usePreviewInteraction({
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 (!canvasRef.current || editingText) return;
+ if (editingText || isMaskMode) return;
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
- const startPos = screenToCanvas({
+ const startPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!startPos) return;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -130,7 +223,7 @@ export function usePreviewInteraction({
originalOpacity: textElement.opacity,
});
},
- [canvasRef, editor, editingText],
+ [editor, editingText, isMaskMode, viewport],
);
const handlePointerDown = useCallback(
@@ -141,8 +234,8 @@ export function usePreviewInteraction({
pointerId,
button,
}: React.PointerEvent) => {
- if (!canvasRef.current) return;
if (editingText) return;
+ if (isMaskMode) return;
if (button !== 0) return;
const tracks = editor.timeline.getTracks();
@@ -150,11 +243,11 @@ export function usePreviewInteraction({
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
- const startPos = screenToCanvas({
+ const startPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!startPos) return;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -163,89 +256,134 @@ export function usePreviewInteraction({
mediaAssets,
});
- const hit = hitTest({
+ const hits = getHitElements({
canvasX: startPos.x,
canvasY: startPos.y,
elementsWithBounds,
});
+ const selectedElements = editor.selection.getSelectedElements();
+ const topmostHit = hits[0] ?? null;
- if (!hit) {
- editor.selection.clearSelection();
- return;
- }
-
- editor.selection.setSelectedElements({
- elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
- });
-
- const elementsWithTracks = editor.timeline.getElementsWithTracks({
- elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
- });
-
- const draggableElements = elementsWithTracks.filter(({ element }) =>
- isVisualElement(element),
- );
-
- if (draggableElements.length === 0) return;
-
- dragStateRef.current = {
+ pendingGestureRef.current = {
startX: startPos.x,
startY: startPos.y,
- bounds: {
- width: hit.bounds.width,
- height: hit.bounds.height,
- },
- elements: draggableElements.map(({ track, element }) => ({
- trackId: track.id,
- elementId: element.id,
- initialTransform: (element as { transform: Transform }).transform,
- })),
+ pointerId,
+ captureTarget: currentTarget as HTMLElement,
+ topmostHit,
+ selectedHit: resolvePreferredHit({
+ hits,
+ preferredElements: selectedElements,
+ }),
+ selectedElements,
};
-
- setIsDragging(true);
currentTarget.setPointerCapture(pointerId);
},
- [editor, canvasRef, editingText],
+ [editor, editingText, isMaskMode, viewport],
);
const handlePointerMove = useCallback(
({ clientX, clientY }: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
-
const canvasSize = editor.project.getActive().settings.canvasSize;
- const currentPos = screenToCanvas({
+ const currentPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!currentPos) return;
- const deltaX = currentPos.x - dragStateRef.current.startX;
- const deltaY = currentPos.y - dragStateRef.current.startY;
- const hasMovement =
- Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
- Math.abs(deltaY) > MIN_DRAG_DISTANCE;
- if (!hasMovement) {
- setSnapLines([]);
- 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 firstElement = dragStateRef.current.elements[0];
+ 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 = screenPixelsToLogicalThreshold({
- canvas: canvasRef.current,
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
const { snappedPosition, activeLines } = shouldSnap
? snapPosition({
proposedPosition,
canvasSize,
- elementSize: dragStateRef.current.bounds,
+ elementSize: dragState.bounds,
+ rotation: dragState.bounds.rotation,
snapThreshold,
})
: {
@@ -253,14 +391,14 @@ export function usePreviewInteraction({
activeLines: [] as SnapLine[],
};
- setSnapLines(activeLines);
+ onSnapLinesChange?.(activeLines);
const deltaSnappedX =
snappedPosition.x - firstElement.initialTransform.position.x;
const deltaSnappedY =
snappedPosition.y - firstElement.initialTransform.position.y;
- const updates = dragStateRef.current.elements.map(
+ const updates = dragState.elements.map(
({ trackId, elementId, initialTransform }) => ({
trackId,
elementId,
@@ -278,38 +416,51 @@ export function usePreviewInteraction({
editor.timeline.previewElements({ updates });
},
- [isDragging, canvasRef, editor, isShiftHeldRef],
+ [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
);
const handlePointerUp = useCallback(
- ({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
+ ({ type }: React.PointerEvent) => {
+ const dragState = dragStateRef.current;
+ if (dragState) {
+ if (type === "pointercancel") {
+ editor.timeline.discardPreview();
+ } else {
+ editor.timeline.commitPreview();
+ }
- const currentPos = screenToCanvas({
- clientX,
- clientY,
- canvas: canvasRef.current,
- });
-
- const deltaX = currentPos.x - dragStateRef.current.startX;
- const deltaY = currentPos.y - dragStateRef.current.startY;
+ dragStateRef.current = null;
+ pendingGestureRef.current = null;
+ setIsDragging(false);
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(dragState);
+ return;
+ }
- const hasMovement =
- Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
- Math.abs(deltaY) > MIN_DRAG_DISTANCE;
+ const pendingGesture = pendingGestureRef.current;
+ if (!pendingGesture) return;
- if (!hasMovement) {
- editor.timeline.discardPreview();
- } else {
- editor.timeline.commitPreview();
+ if (type !== "pointercancel") {
+ const clickTarget = pendingGesture.topmostHit;
+ if (!clickTarget) {
+ editor.selection.clearSelection();
+ } else {
+ editor.selection.setSelectedElements({
+ elements: [
+ {
+ trackId: clickTarget.trackId,
+ elementId: clickTarget.elementId,
+ },
+ ],
+ });
+ }
}
- dragStateRef.current = null;
- setIsDragging(false);
- setSnapLines([]);
- currentTarget.releasePointerCapture(pointerId);
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
},
- [isDragging, canvasRef, editor],
+ [editor, onSnapLinesChange, releaseCapturedPointer],
);
return {
@@ -317,9 +468,7 @@ export function usePreviewInteraction({
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onDoubleClick: handleDoubleClick,
- snapLines,
editingText,
commitTextEdit,
- cancelTextEdit,
};
}