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
@@ -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>
);
}
@@ -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}
/>
);
}
@@ -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
@@ -357,6 +357,7 @@ export function Timeline() {
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
rulerRef={rulerRef}
tracksScrollRef={tracksScrollRef}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}
@@ -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;