summaryrefslogtreecommitdiff
path: root/apps/web
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
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')
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx34
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx20
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx268
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx1
-rw-r--r--apps/web/src/components/editor/timeline/timeline-ruler.tsx26
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts27
-rw-r--r--apps/web/src/hooks/timeline/use-scroll-position.ts52
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts177
-rw-r--r--apps/web/src/lib/commands/batch-command.ts25
-rw-r--r--apps/web/src/lib/commands/index.ts1
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts71
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts53
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts67
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts67
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts4
15 files changed, 631 insertions, 262 deletions
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
index 5ed9db00..c579b15b 100644
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -8,6 +8,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
import { getLastFrameTime } from "@/lib/time";
+import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
function usePreviewSize() {
const editor = useEditor();
@@ -57,7 +58,7 @@ export function PreviewPanel() {
}
function PreviewCanvas() {
- const ref = useRef<HTMLCanvasElement>(null);
+ const canvasRef = useRef<HTMLCanvasElement>(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(null);
const renderingRef = useRef(false);
@@ -76,7 +77,7 @@ function PreviewCanvas() {
const renderTree = editor.renderer.getRenderTree();
const render = useCallback(() => {
- if (ref.current && renderTree && !renderingRef.current) {
+ if (canvasRef.current && renderTree && !renderingRef.current) {
const time = editor.playback.getCurrentTime();
const lastFrameTime = getLastFrameTime({
duration: renderTree.duration,
@@ -96,7 +97,7 @@ function PreviewCanvas() {
.renderToCanvas({
node: renderTree,
time: renderTime,
- targetCanvas: ref.current,
+ targetCanvas: canvasRef.current,
})
.then(() => {
renderingRef.current = false;
@@ -108,17 +109,20 @@ function PreviewCanvas() {
useRafLoop(render);
return (
- <canvas
- ref={ref}
- width={width}
- height={height}
- className="block max-h-full max-w-full border"
- style={{
- background:
- activeProject.settings.background.type === "blur"
- ? "transparent"
- : activeProject?.settings.background.color,
- }}
- />
+ <div className="relative">
+ <canvas
+ ref={canvasRef}
+ width={width}
+ height={height}
+ className="block max-h-full max-w-full border"
+ style={{
+ background:
+ activeProject.settings.background.type === "blur"
+ ? "transparent"
+ : activeProject?.settings.background.color,
+ }}
+ />
+ <PreviewInteractionOverlay canvasRef={canvasRef} />
+ </div>
);
}
diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
new file mode 100644
index 00000000..c13379f0
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
@@ -0,0 +1,20 @@
+import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
+import { cn } from "@/utils/ui";
+
+export function PreviewInteractionOverlay({
+ canvasRef,
+}: {
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+}) {
+ const { onPointerDown, onPointerMove, onPointerUp } =
+ usePreviewInteraction({ canvasRef });
+
+ return (
+ <div
+ className={cn("absolute inset-0 pointer-events-auto")}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx
index 43c1eb26..35230c75 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -50,6 +50,8 @@ export function TextProperties({
);
const lastSelectedColor = useRef(DEFAULT_COLOR);
+ const initialFontSizeRef = useRef<number | null>(null);
+ const initialOpacityRef = useRef<number | null>(null);
const handleFontSizeChange = ({ value }: { value: string }) => {
setFontSizeInput(value);
@@ -59,10 +61,8 @@ export function TextProperties({
const fontSize = Number.isNaN(parsed)
? element.fontSize
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { fontSize },
+ editor.timeline.updateElements({
+ updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
});
}
};
@@ -73,10 +73,8 @@ export function TextProperties({
? element.fontSize
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
setFontSizeInput(fontSize.toString());
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { fontSize },
+ editor.timeline.updateElements({
+ updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
});
};
@@ -88,10 +86,14 @@ export function TextProperties({
const opacityPercent = Number.isNaN(parsed)
? Math.round(element.opacity * 100)
: clamp({ value: parsed, min: 0, max: 100 });
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { opacity: opacityPercent / 100 },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { opacity: opacityPercent / 100 },
+ },
+ ],
});
}
};
@@ -102,10 +104,14 @@ export function TextProperties({
? Math.round(element.opacity * 100)
: clamp({ value: parsed, min: 0, max: 100 });
setOpacityInput(opacityPercent.toString());
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { opacity: opacityPercent / 100 },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { opacity: opacityPercent / 100 },
+ },
+ ],
});
};
@@ -113,10 +119,14 @@ export function TextProperties({
if (color !== "transparent") {
lastSelectedColor.current = color;
}
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { backgroundColor: color },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { backgroundColor: color },
+ },
+ ],
});
};
@@ -126,10 +136,14 @@ export function TextProperties({
isTransparent: boolean;
}) => {
const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { backgroundColor: newColor },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { backgroundColor: newColor },
+ },
+ ],
});
};
@@ -154,10 +168,14 @@ export function TextProperties({
defaultValue={element.content}
className="bg-panel-accent min-h-20"
onChange={(e) =>
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { content: e.target.value },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { content: e.target.value },
+ },
+ ],
})
}
/>
@@ -167,10 +185,14 @@ export function TextProperties({
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value: FontFamily) =>
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { fontFamily: value },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontFamily: value },
+ },
+ ],
})
}
/>
@@ -186,13 +208,19 @@ export function TextProperties({
}
size="sm"
onClick={() =>
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: {
- fontWeight:
- element.fontWeight === "bold" ? "normal" : "bold",
- },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ fontWeight:
+ element.fontWeight === "bold"
+ ? "normal"
+ : "bold",
+ },
+ },
+ ],
})
}
className="h-8 px-3 font-bold"
@@ -205,15 +233,19 @@ export function TextProperties({
}
size="sm"
onClick={() =>
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: {
- fontStyle:
- element.fontStyle === "italic"
- ? "normal"
- : "italic",
- },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ fontStyle:
+ element.fontStyle === "italic"
+ ? "normal"
+ : "italic",
+ },
+ },
+ ],
})
}
className="h-8 px-3 italic"
@@ -228,15 +260,19 @@ export function TextProperties({
}
size="sm"
onClick={() =>
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: {
- textDecoration:
- element.textDecoration === "underline"
- ? "none"
- : "underline",
- },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ textDecoration:
+ element.textDecoration === "underline"
+ ? "none"
+ : "underline",
+ },
+ },
+ ],
})
}
className="h-8 px-3 underline"
@@ -251,15 +287,19 @@ export function TextProperties({
}
size="sm"
onClick={() =>
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: {
- textDecoration:
- element.textDecoration === "line-through"
- ? "none"
- : "line-through",
- },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ textDecoration:
+ element.textDecoration === "line-through"
+ ? "none"
+ : "line-through",
+ },
+ },
+ ],
})
}
className="h-8 px-3 line-through"
@@ -279,13 +319,48 @@ export function TextProperties({
max={MAX_FONT_SIZE}
step={1}
onValueChange={([value]) => {
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { fontSize: value },
+ if (initialFontSizeRef.current === null) {
+ initialFontSizeRef.current = element.fontSize;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontSize: value },
+ },
+ ],
+ pushHistory: false,
});
setFontSizeInput(value.toString());
}}
+ onValueCommit={([value]) => {
+ if (initialFontSizeRef.current !== null) {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ fontSize: initialFontSizeRef.current,
+ },
+ },
+ ],
+ pushHistory: false,
+ });
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontSize: value },
+ },
+ ],
+ pushHistory: true,
+ });
+ initialFontSizeRef.current = null;
+ }
+ }}
className="w-full"
/>
<Input
@@ -310,10 +385,14 @@ export function TextProperties({
string: (element.color || "FFFFFF").replace("#", ""),
})}
onChange={(color) => {
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { color: `#${color}` },
+ },
+ ],
});
}}
containerRef={containerRef}
@@ -330,13 +409,46 @@ export function TextProperties({
max={100}
step={1}
onValueChange={([value]) => {
- editor.timeline.updateElement({
- trackId,
- elementId: element.id,
- updates: { opacity: value / 100 },
+ if (initialOpacityRef.current === null) {
+ initialOpacityRef.current = element.opacity;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { opacity: value / 100 },
+ },
+ ],
+ pushHistory: false,
});
setOpacityInput(value.toString());
}}
+ onValueCommit={([value]) => {
+ if (initialOpacityRef.current !== null) {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { opacity: initialOpacityRef.current },
+ },
+ ],
+ pushHistory: false,
+ });
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { opacity: value / 100 },
+ },
+ ],
+ pushHistory: true,
+ });
+ initialOpacityRef.current = null;
+ }
+ }}
className="w-full"
/>
<Input
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index 271bad9b..2ff17345 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -357,6 +357,7 @@ export function Timeline() {
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
rulerRef={rulerRef}
+ tracksScrollRef={tracksScrollRef}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}
diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx
index f5334f5a..dfa0534b 100644
--- a/apps/web/src/components/editor/timeline/timeline-ruler.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-ruler.tsx
@@ -3,12 +3,14 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
+import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
import { TimelineTick } from "./timeline-tick";
interface TimelineRulerProps {
zoomLevel: number;
dynamicTimelineWidth: number;
rulerRef: React.Ref<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLElement | null>;
handleWheel: (e: React.WheelEvent) => void;
handleTimelineContentClick: (e: React.MouseEvent) => void;
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
@@ -19,6 +21,7 @@ export function TimelineRuler({
zoomLevel,
dynamicTimelineWidth,
rulerRef,
+ tracksScrollRef,
handleWheel,
handleTimelineContentClick,
handleRulerTrackingMouseDown,
@@ -37,8 +40,29 @@ export function TimelineRuler({
});
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
+ const { scrollLeft, viewportWidth } = useScrollPosition({
+ scrollRef: tracksScrollRef,
+ });
+
+ const bufferPx = 200;
+ const visibleStartTime = Math.max(
+ 0,
+ (scrollLeft - bufferPx) / pixelsPerSecond,
+ );
+ const visibleEndTime =
+ (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
+
+ const startTickIndex = Math.max(
+ 0,
+ Math.floor(visibleStartTime / tickIntervalSeconds),
+ );
+ const endTickIndex = Math.min(
+ tickCount - 1,
+ Math.ceil(visibleEndTime / tickIntervalSeconds),
+ );
+
const timelineTicks: Array<JSX.Element> = [];
- for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
+ for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; tickIndex += 1) {
const time = tickIndex * tickIntervalSeconds;
if (time > effectiveDuration) break;
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 22cba52d..ad9f7792 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -24,6 +24,7 @@ import {
UpdateElementStartTimeCommand,
MoveElementCommand,
} from "@/lib/commands/timeline";
+import { BatchCommand } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
@@ -198,17 +199,27 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
- updateElement({
- trackId,
- elementId,
+ updateElements({
updates,
+ pushHistory = true,
}: {
- trackId: string;
- elementId: string;
- updates: Partial<Record<string, unknown>>;
+ updates: Array<{
+ trackId: string;
+ elementId: string;
+ updates: Partial<Record<string, unknown>>;
+ }>;
+ pushHistory?: boolean;
}): void {
- const command = new UpdateElementCommand(trackId, elementId, updates);
- this.editor.command.execute({ command });
+ const commands = updates.map(
+ ({ trackId, elementId, updates: elementUpdates }) =>
+ new UpdateElementCommand(trackId, elementId, elementUpdates),
+ );
+ const command = commands.length === 1 ? commands[0] : new BatchCommand(commands);
+ if (pushHistory) {
+ this.editor.command.execute({ command });
+ } else {
+ command.execute();
+ }
}
duplicateElements({
diff --git a/apps/web/src/hooks/timeline/use-scroll-position.ts b/apps/web/src/hooks/timeline/use-scroll-position.ts
new file mode 100644
index 00000000..ad2c6374
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-scroll-position.ts
@@ -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 };
+}
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,
+ };
+}
diff --git a/apps/web/src/lib/commands/batch-command.ts b/apps/web/src/lib/commands/batch-command.ts
new file mode 100644
index 00000000..05b51d38
--- /dev/null
+++ b/apps/web/src/lib/commands/batch-command.ts
@@ -0,0 +1,25 @@
+import { Command } from "./base-command";
+
+export class BatchCommand extends Command {
+ constructor(private commands: Command[]) {
+ super();
+ }
+
+ execute(): void {
+ for (const command of this.commands) {
+ command.execute();
+ }
+ }
+
+ undo(): void {
+ for (const command of [...this.commands].reverse()) {
+ command.undo();
+ }
+ }
+
+ redo(): void {
+ for (const command of this.commands) {
+ command.execute();
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts
index 6700087b..03de07c4 100644
--- a/apps/web/src/lib/commands/index.ts
+++ b/apps/web/src/lib/commands/index.ts
@@ -1,4 +1,5 @@
export { Command } from "./base-command";
+export { BatchCommand } from "./batch-command";
export * from "./timeline";
export * from "./media";
diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts
index 5405b60b..49e84ebe 100644
--- a/apps/web/src/services/renderer/nodes/image-node.ts
+++ b/apps/web/src/services/renderer/nodes/image-node.ts
@@ -1,22 +1,11 @@
import type { CanvasRenderer } from "../canvas-renderer";
-import { BaseNode } from "./base-node";
+import { VisualNode, type VisualNodeParams } from "./visual-node";
-const IMAGE_EPSILON = 1 / 1000;
-
-export interface ImageNodeParams {
+export interface ImageNodeParams extends VisualNodeParams {
url: string;
- duration: number;
- timeOffset: number;
- trimStart: number;
- trimEnd: number;
- x?: number;
- y?: number;
- width?: number;
- height?: number;
- opacity?: number;
}
-export class ImageNode extends BaseNode<ImageNodeParams> {
+export class ImageNode extends VisualNode<ImageNodeParams> {
private image?: HTMLImageElement;
private readyPromise: Promise<void>;
@@ -36,18 +25,6 @@ export class ImageNode extends BaseNode<ImageNodeParams> {
});
}
- private getImageTime(time: number) {
- return time - this.params.timeOffset + this.params.trimStart;
- }
-
- private isInRange(time: number) {
- const imageTime = this.getImageTime(time);
- return (
- imageTime >= this.params.trimStart - IMAGE_EPSILON &&
- imageTime < this.params.trimStart + this.params.duration
- );
- }
-
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
@@ -61,40 +38,14 @@ export class ImageNode extends BaseNode<ImageNodeParams> {
return;
}
- renderer.context.save();
+ const mediaW = this.image.naturalWidth || renderer.width;
+ const mediaH = this.image.naturalHeight || renderer.height;
- if (this.params.opacity !== undefined) {
- renderer.context.globalAlpha = this.params.opacity;
- }
-
- if (
- this.params.x !== undefined &&
- this.params.y !== undefined &&
- this.params.width !== undefined &&
- this.params.height !== undefined
- ) {
- renderer.context.drawImage(
- this.image,
- this.params.x,
- this.params.y,
- this.params.width,
- this.params.height,
- );
- } else {
- const mediaW = this.image.naturalWidth || renderer.width;
- const mediaH = this.image.naturalHeight || renderer.height;
- const containScale = Math.min(
- renderer.width / mediaW,
- renderer.height / mediaH,
- );
- const drawW = mediaW * containScale;
- const drawH = mediaH * containScale;
- const drawX = (renderer.width - drawW) / 2;
- const drawY = (renderer.height - drawH) / 2;
-
- renderer.context.drawImage(this.image, drawX, drawY, drawW, drawH);
- }
-
- renderer.context.restore();
+ this.renderVisual({
+ renderer,
+ source: this.image,
+ sourceWidth: mediaW,
+ sourceHeight: mediaH,
+ });
}
}
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index 5af13a6f..9842320e 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -1,21 +1,12 @@
import type { CanvasRenderer } from "../canvas-renderer";
-import { BaseNode, type BaseNodeParams } from "./base-node";
-import type { Transform } from "@/types/timeline";
+import { VisualNode, type VisualNodeParams } from "./visual-node";
-const STICKER_EPSILON = 1 / 1000;
-
-export type StickerNodeParams = BaseNodeParams & {
+export interface StickerNodeParams extends VisualNodeParams {
iconName: string;
- duration: number;
- timeOffset: number;
- trimStart: number;
- trimEnd: number;
- transform: Transform;
- opacity: number;
color?: string;
-};
+}
-export class StickerNode extends BaseNode<StickerNodeParams> {
+export class StickerNode extends VisualNode<StickerNodeParams> {
private image?: HTMLImageElement;
private readyPromise: Promise<void>;
@@ -40,18 +31,6 @@ export class StickerNode extends BaseNode<StickerNodeParams> {
});
}
- private getStickerTime(time: number) {
- return time - this.params.timeOffset + this.params.trimStart;
- }
-
- private isInRange(time: number) {
- const stickerTime = this.getStickerTime(time);
- return (
- stickerTime >= this.params.trimStart - STICKER_EPSILON &&
- stickerTime < this.params.trimStart + this.params.duration
- );
- }
-
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
@@ -65,23 +44,11 @@ export class StickerNode extends BaseNode<StickerNodeParams> {
return;
}
- const { transform, opacity } = this.params;
- const size = 200 * transform.scale;
- const x = renderer.width / 2 + transform.position.x - size / 2;
- const y = renderer.height / 2 + transform.position.y - size / 2;
-
- renderer.context.save();
- renderer.context.globalAlpha = opacity;
-
- if (transform.rotate !== 0) {
- const centerX = x + size / 2;
- const centerY = y + size / 2;
- renderer.context.translate(centerX, centerY);
- renderer.context.rotate((transform.rotate * Math.PI) / 180);
- renderer.context.translate(-centerX, -centerY);
- }
-
- renderer.context.drawImage(this.image, x, y, size, size);
- renderer.context.restore();
+ this.renderVisual({
+ renderer,
+ source: this.image,
+ sourceWidth: 200,
+ sourceHeight: 200,
+ });
}
}
diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts
index 841eb459..8e2ddccd 100644
--- a/apps/web/src/services/renderer/nodes/video-node.ts
+++ b/apps/web/src/services/renderer/nodes/video-node.ts
@@ -1,37 +1,14 @@
import type { CanvasRenderer } from "../canvas-renderer";
-import { BaseNode } from "./base-node";
+import { VisualNode, type VisualNodeParams } from "./visual-node";
import { videoCache } from "@/services/video-cache/service";
-const VIDEO_EPSILON = 1 / 1000;
-
-export interface VideoNodeParams {
+export interface VideoNodeParams extends VisualNodeParams {
url: string;
file: File;
mediaId: string;
- duration: number;
- timeOffset: number;
- trimStart: number;
- trimEnd: number;
- x?: number;
- y?: number;
- width?: number;
- height?: number;
- opacity?: number;
}
-export class VideoNode extends BaseNode<VideoNodeParams> {
- private getVideoTime(time: number) {
- return time - this.params.timeOffset + this.params.trimStart;
- }
-
- private isInRange(time: number) {
- const videoTime = this.getVideoTime(time);
- return (
- videoTime >= this.params.trimStart - VIDEO_EPSILON &&
- videoTime < this.params.trimStart + this.params.duration
- );
- }
-
+export class VideoNode extends VisualNode<VideoNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
@@ -39,7 +16,7 @@ export class VideoNode extends BaseNode<VideoNodeParams> {
return;
}
- const videoTime = this.getVideoTime(time);
+ const videoTime = this.getLocalTime(time);
const frame = await videoCache.getFrameAt({
mediaId: this.params.mediaId,
file: this.params.file,
@@ -47,36 +24,12 @@ export class VideoNode extends BaseNode<VideoNodeParams> {
});
if (frame) {
- renderer.context.save();
-
- if (this.params.opacity !== undefined) {
- renderer.context.globalAlpha = this.params.opacity;
- }
-
- if (
- this.params.x !== undefined &&
- this.params.y !== undefined &&
- this.params.width !== undefined &&
- this.params.height !== undefined
- ) {
- renderer.context.drawImage(
- frame.canvas,
- this.params.x,
- this.params.y,
- this.params.width,
- this.params.height,
- );
- } else {
- renderer.context.drawImage(
- frame.canvas,
- 0,
- 0,
- renderer.width,
- renderer.height,
- );
- }
-
- renderer.context.restore();
+ this.renderVisual({
+ renderer,
+ source: frame.canvas,
+ sourceWidth: frame.canvas.width,
+ sourceHeight: frame.canvas.height,
+ });
}
}
}
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
new file mode 100644
index 00000000..9c6c88ca
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -0,0 +1,67 @@
+import type { CanvasRenderer } from "../canvas-renderer";
+import { BaseNode } from "./base-node";
+import type { Transform } from "@/types/timeline";
+
+const VISUAL_EPSILON = 1 / 1000;
+
+export interface VisualNodeParams {
+ duration: number;
+ timeOffset: number;
+ trimStart: number;
+ trimEnd: number;
+ transform: Transform;
+ opacity: number;
+}
+
+export abstract class VisualNode<
+ Params extends VisualNodeParams = VisualNodeParams,
+> extends BaseNode<Params> {
+ protected getLocalTime(time: number): number {
+ return time - this.params.timeOffset + this.params.trimStart;
+ }
+
+ protected isInRange(time: number): boolean {
+ const localTime = this.getLocalTime(time);
+ return (
+ localTime >= this.params.trimStart - VISUAL_EPSILON &&
+ localTime < this.params.trimStart + this.params.duration
+ );
+ }
+
+ protected renderVisual({
+ renderer,
+ source,
+ sourceWidth,
+ sourceHeight,
+ }: {
+ renderer: CanvasRenderer;
+ source: CanvasImageSource;
+ sourceWidth: number;
+ sourceHeight: number;
+ }): void {
+ renderer.context.save();
+
+ const { transform, opacity } = this.params;
+ const containScale = Math.min(
+ renderer.width / sourceWidth,
+ renderer.height / sourceHeight,
+ );
+ const scaledWidth = sourceWidth * containScale * transform.scale;
+ const scaledHeight = sourceHeight * containScale * transform.scale;
+ const x = renderer.width / 2 + transform.position.x - scaledWidth / 2;
+ const y = renderer.height / 2 + transform.position.y - scaledHeight / 2;
+
+ renderer.context.globalAlpha = opacity;
+
+ if (transform.rotate !== 0) {
+ const centerX = x + scaledWidth / 2;
+ const centerY = y + scaledHeight / 2;
+ renderer.context.translate(centerX, centerY);
+ renderer.context.rotate((transform.rotate * Math.PI) / 180);
+ renderer.context.translate(-centerX, -centerY);
+ }
+
+ renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ renderer.context.restore();
+ }
+}
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index 5d33524e..d1b8217e 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -64,6 +64,8 @@ export function buildScene(params: BuildSceneParams) {
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
+ transform: element.transform,
+ opacity: element.opacity,
}),
);
}
@@ -75,6 +77,8 @@ export function buildScene(params: BuildSceneParams) {
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
+ transform: element.transform,
+ opacity: element.opacity,
}),
);
}