From e50a817b97b87baa0cd8fdde0fdb5d607143c1c3 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 8 Feb 2026 22:21:17 +0100 Subject: add transform + few fixes feat: transform feat: batch command fix: slider undo fix: timeline zoom perf refactor: make updateElement support multiple --- .../src/components/editor/panels/preview/index.tsx | 34 +-- .../panels/preview/preview-interaction-overlay.tsx | 20 ++ .../editor/panels/properties/text-properties.tsx | 268 +++++++++++++++------ apps/web/src/components/editor/timeline/index.tsx | 1 + .../components/editor/timeline/timeline-ruler.tsx | 26 +- 5 files changed, 255 insertions(+), 94 deletions(-) create mode 100644 apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx (limited to 'apps/web/src/components') 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(null); + const canvasRef = useRef(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef(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 ( - +
+ + +
); } 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; +}) { + const { onPointerDown, onPointerMove, onPointerUp } = + usePreviewInteraction({ canvasRef }); + + return ( +
+ ); +} 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(null); + const initialOpacityRef = useRef(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({ - 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" /> { - 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" /> ; + tracksScrollRef: React.RefObject; 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 = []; - for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) { + for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; tickIndex += 1) { const time = tickIndex * tickIntervalSeconds; if (time > effectiveDuration) break; -- cgit v1.3.1