summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/use-preview-interaction.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-08 22:21:17 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-08 22:21:17 +0100
commite50a817b97b87baa0cd8fdde0fdb5d607143c1c3 (patch)
tree8f33d19153be9a6d9fe81b1a7e7a54179ab88feb /apps/web/src/hooks/use-preview-interaction.ts
parent484ce47867b1c92c83e4cfab9fa8371bb6e68e12 (diff)
downloadopencut-e50a817b97b87baa0cd8fdde0fdb5d607143c1c3.tar.gz
add transform + few fixes
feat: transform feat: batch command fix: slider undo fix: timeline zoom perf refactor: make updateElement support multiple
Diffstat (limited to 'apps/web/src/hooks/use-preview-interaction.ts')
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts177
1 files changed, 177 insertions, 0 deletions
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
new file mode 100644
index 00000000..6aeb0c9b
--- /dev/null
+++ b/apps/web/src/hooks/use-preview-interaction.ts
@@ -0,0 +1,177 @@
+import { useCallback, useRef, useState } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { useSyncExternalStore } from "react";
+import type { Transform, TimelineTrack } from "@/types/timeline";
+
+interface DragState {
+ startX: number;
+ startY: number;
+ tracksSnapshot: TimelineTrack[];
+ elements: Array<{
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ }>;
+}
+
+export function usePreviewInteraction({
+ canvasRef,
+}: {
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+}) {
+ const editor = useEditor();
+ const [isDragging, setIsDragging] = useState(false);
+ const dragStateRef = useRef<DragState | null>(null);
+
+ const selectedElements = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getSelectedElements(),
+ );
+
+ const getCanvasCoordinates = useCallback(
+ ({ clientX, clientY }: { clientX: number; clientY: number }) => {
+ if (!canvasRef.current) return { x: 0, y: 0 };
+
+ const rect = canvasRef.current.getBoundingClientRect();
+ const logicalWidth = canvasRef.current.width;
+ const logicalHeight = canvasRef.current.height;
+ const scaleX = logicalWidth / rect.width;
+ const scaleY = logicalHeight / rect.height;
+
+ const canvasX = (clientX - rect.left) * scaleX;
+ const canvasY = (clientY - rect.top) * scaleY;
+
+ return { x: canvasX, y: canvasY };
+ },
+ [canvasRef],
+ );
+
+ const handlePointerDown = useCallback(
+ (event: React.PointerEvent) => {
+ if (selectedElements.length === 0) return;
+
+ const elementsWithTracks = editor.timeline.getElementsWithTracks({
+ elements: selectedElements,
+ });
+
+ const draggableElements = elementsWithTracks.filter(
+ ({ element }) =>
+ element.type === "video" ||
+ element.type === "image" ||
+ element.type === "text" ||
+ element.type === "sticker",
+ );
+
+ if (draggableElements.length === 0) return;
+
+ const startPos = getCanvasCoordinates({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+
+ dragStateRef.current = {
+ startX: startPos.x,
+ startY: startPos.y,
+ tracksSnapshot: editor.timeline.getTracks(),
+ elements: draggableElements.map(({ track, element }) => ({
+ trackId: track.id,
+ elementId: element.id,
+ initialTransform: (element as { transform: Transform }).transform,
+ })),
+ };
+
+ setIsDragging(true);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ },
+ [selectedElements, editor, getCanvasCoordinates],
+ );
+
+ const handlePointerMove = useCallback(
+ (event: React.PointerEvent) => {
+ if (!dragStateRef.current || !isDragging) return;
+
+ const currentPos = getCanvasCoordinates({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+
+ const deltaX = currentPos.x - dragStateRef.current.startX;
+ const deltaY = currentPos.y - dragStateRef.current.startY;
+
+ for (const { trackId, elementId, initialTransform } of dragStateRef
+ .current.elements) {
+ const newPosition = {
+ x: initialTransform.position.x + deltaX,
+ y: initialTransform.position.y + deltaY,
+ };
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ position: newPosition,
+ },
+ },
+ },
+ ],
+ pushHistory: false,
+ });
+ }
+ },
+ [isDragging, getCanvasCoordinates, editor],
+ );
+
+ const handlePointerUp = useCallback(
+ (event: React.PointerEvent) => {
+ if (!dragStateRef.current || !isDragging) return;
+
+ const currentPos = getCanvasCoordinates({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+
+ const deltaX = currentPos.x - dragStateRef.current.startX;
+ const deltaY = currentPos.y - dragStateRef.current.startY;
+
+ // revert to pre-drag state so the command captures the correct undo snapshot
+ editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot);
+
+ const updates = dragStateRef.current.elements.map(
+ ({ trackId, elementId, initialTransform }) => {
+ const newPosition = {
+ x: initialTransform.position.x + deltaX,
+ y: initialTransform.position.y + deltaY,
+ };
+
+ return {
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ position: newPosition,
+ },
+ },
+ };
+ },
+ );
+
+ editor.timeline.updateElements({ updates });
+
+ dragStateRef.current = null;
+ setIsDragging(false);
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ },
+ [isDragging, getCanvasCoordinates, editor],
+ );
+
+ return {
+ onPointerDown: handlePointerDown,
+ onPointerMove: handlePointerMove,
+ onPointerUp: handlePointerUp,
+ };
+}