add transform + few fixes

feat: transform
feat: batch command
fix: slider undo
fix: timeline zoom perf
refactor: make updateElement support multiple
This commit is contained in:
Maze Winther
2026-02-08 22:21:17 +01:00
parent 484ce47867
commit e50a817b97
15 changed files with 631 additions and 262 deletions
@@ -0,0 +1,52 @@
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 };
}
@@ -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,
};
}