mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user