summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor
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/components/editor
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/components/editor')
-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
5 files changed, 255 insertions, 94 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;